Compare commits

...

8 Commits

Author SHA1 Message Date
Valdemar Lorenzen cd2e3da330 fix: complete Command presentation teardown 2026-10-16 17:06:14 +02:00
Valdemar Lorenzen 586c8a7560 docs: record Command presentation lifetime cleanup 2026-10-16 15:35:39 +02:00
Valdemar Lorenzen f8588adebe feat: copy and paste selected emoji 2026-10-16 15:15:34 +02:00
Valdemar Lorenzen 9979639804 perf: reduce launcher presentation latency 2026-10-16 15:10:29 +02:00
Valdemar Lorenzen a6ea75821b refactor: own daily currency calculation lifetime 2026-10-16 15:05:33 +02:00
Valdemar Lorenzen c8b2e8e0c5 fix: trust native global shortcut registration 2026-10-16 14:11:30 +02:00
Valdemar Lorenzen 6725be8ad5 refactor: centralize shortcut recording lifetime 2026-10-16 12:06:41 +02:00
Valdemar Lorenzen 04e505b846 refactor: deepen Translate execution testing 2026-10-16 11:29:18 +02:00
17 changed files with 892 additions and 194 deletions
+12
View File
@@ -0,0 +1,12 @@
# Conjure
A native macOS launcher for Applications, Commands, and calculations.
## Language
**Currency calculation**:
A conversion of an amount between fiat currencies or cryptocurrencies using daily rates, not a live market quote.
**Daily rate snapshot**:
A provider's dated collection of exchange rates. Its snapshot date identifies the rates, not when Conjure retrieved them.
_Avoid_: Live rates
+15 -3
View File
@@ -35,6 +35,8 @@ Conjure searches the system and user Applications folders. Hover over a result t
Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut. Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut.
The launcher opens without a window animation; Settings is constructed only when needed, not before the launcher appears.
### Controls ### Controls
| Action | Control | | Action | Control |
@@ -56,9 +58,11 @@ In Settings, choose a **Global shortcut destination**: **Applications**, **Conju
The Applications shortcut always targets the application launcher, not the last-used tool. Command shortcuts open their selected tool directly. Pressing a shortcut while its destination is already frontmost dismisses Conjure; switching destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation. The Applications shortcut always targets the application launcher, not the last-used tool. Command shortcuts open their selected tool directly. Pressing a shortcut while its destination is already frontmost dismisses Conjure; switching destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
Cancelling recording, changing destination, or leaving the recorder restores saved shortcuts. If a replacement cannot be registered, the previous saved binding is retained; any failure to restore a shortcut is reported in Settings.
For Command-Space, select **Applications** and choose **Use ⌘Space**. If it conflicts with Spotlight, Input Sources, or another app, change that binding in System Settings first. Conjure never changes macOS shortcuts for you. Shortcuts work while Conjure is running; they do not start a quit app or require Accessibility permission. For Command-Space, select **Applications** and choose **Use ⌘Space**. If it conflicts with Spotlight, Input Sources, or another app, change that binding in System Settings first. Conjure never changes macOS shortcuts for you. Shortcuts work while Conjure is running; they do not start a quit app or require Accessibility permission.
On macOS 27, [Siri AI uses Command-Shift-Space to select a window for screen capture](https://support.apple.com/en-is/guide/mac-help/iphmk4p88mkuky/mac). That system reservation can conflict even though it is not listed under Spotlight or Input Sources. Use another binding unless you have freed the Siri shortcut. Conjure lets macOS decide whether a global shortcut can be registered, rather than rejecting it solely because it appears in the system shortcut table. A registered shortcut can still compete with system behavior; if it does, change the conflicting system shortcut or choose another binding.
Install Conjure in `/Applications`, then enable **Launch at login** in Settings to start it in the background. If macOS requires approval, use **Open Login Items Settings…** to grant it. Install Conjure in `/Applications`, then enable **Launch at login** in Settings to start it in the background. If macOS requires approval, use **Open Login Items Settings…** to grant it.
@@ -83,13 +87,19 @@ Convert length, mass, temperature, volume, duration, speed, area, and data with
Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry. Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry.
Rates are shared across queries and refreshed on the next currency query after the local day changes. While refreshing, a cached answer remains copyable and is labeled stale. Leaving the query or dismissing the launcher does not cancel that shared refresh.
`ConjureKit.CurrencyCalculation` owns this lifetime policy; the launcher renders its result. Its checks use an injected clock and scripted rate fetches.
## Emoji picker ## Emoji picker
Search for **Conjure Emoji**, identified by its centered 🤔 icon, and open it. Browse the grid or search by name, keyword, `:shortcode:`, or emoji. Use the category menu to filter results or show **Recently Used**. Your last 30 selections are saved locally. Favorites appear first, followed by recent selections when browsing All Emoji. Search for **Conjure Emoji**, identified by its centered 🤔 icon, and open it. Browse the grid or search by name, keyword, `:shortcode:`, or emoji. Use the category menu to filter results or show **Recently Used**. Your last 30 selections are saved locally. Favorites appear first, followed by recent selections when browsing All Emoji.
Use the arrow keys or Control-H/J/K/L to navigate left/down/up/right. Return or clicking an emoji copies it and dismisses Conjure, restoring focus to the previous app. Paste with Command-V; no Accessibility permission is needed. Escape or the Back chevron returns to the launcher and restores your search. Use the arrow keys or Control-H/J/K/L to navigate left/down/up/right. Return, clicking an emoji, or **Copy and Paste** copies it, dismisses Conjure, and tries to paste into the app that was focused when the launcher opened. Automatic pasting requires **System Settings → Privacy & Security → Accessibility → Conjure**; macOS prompts if permission is missing. After granting it, select an emoji again. If permission is denied, the previous app is unavailable, or focus cannot be restored promptly, the emoji stays copied for manual Command-V. Escape or the Back chevron returns to the launcher and restores your search.
Open **Actions** with Command-K and choose **Favorite** or **Remove Favorite**. Favorites are saved locally and grouped ahead of other emoji, with a small extra spacing break when browsing without a search. There are no star badges. Open **Actions** with Command-K for **Copy and Paste** (default), **Copy Emoji** (copy only, no Accessibility permission needed), **Favorite**, or **Remove Favorite**. Favorites are saved locally and grouped ahead of other emoji, with a small extra spacing break when browsing without a search. There are no star badges.
With Actions open, Escape or the Back chevron closes Actions first; another Back returns to Applications and restores your search. Command-W dismisses the entire launcher, including the current Command and its Actions. Switching destinations also removes the previous Command's Actions completely. Copy and Copy and Paste dismiss the launcher without an intermediate Back step.
The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application. The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application.
@@ -97,6 +107,8 @@ The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.co
Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input or Escape returns to the launcher and restores your search. Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input or Escape returns to the launcher and restores your search.
Editing the input, switching providers, forgetting the key, or leaving Translate cancels the active request; cancelled results are not shown. After a request fails, submit again to retry.
- **MyMemory:** No account or key required. Limits are 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php) for anonymous use. - **MyMemory:** No account or key required. Limits are 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php) for anonymous use.
- **OpenAI:** Requires an API key, access to `gpt-6-luna`, and sufficient API billing. Choose **Save Key** to store it in macOS Keychain; **Forget Key** removes it. Requests disable API response storage, but OpenAI's retention policies still apply. - **OpenAI:** Requires an API key, access to `gpt-6-luna`, and sufficient API billing. Choose **Save Key** to store it in macOS Keychain; **Forget Key** removes it. Requests disable API response storage, but OpenAI's retention policies still apply.
@@ -10,7 +10,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text), ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text), ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text),
ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text), ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed) ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed),
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: SunsetPalette.text)
] ]
private var visibleEntries: [Int] = [] private var visibleEntries: [Int] = []
private var availableEntries = Array(0..<6) private var availableEntries = Array(0..<6)
@@ -26,6 +27,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private var transitionGeneration = 0 private var transitionGeneration = 0
private var panel: ActionsPanelView { view as! ActionsPanelView } private var panel: ActionsPanelView { view as! ActionsPanelView }
var open: (() -> Void)? var open: (() -> Void)?
var copyEmoji: (() -> Void)?
var toggleFavorite: (() -> Void)? var toggleFavorite: (() -> Void)?
var isFavorite = false var isFavorite = false
var showInFinder: (() -> Void)? var showInFinder: (() -> Void)?
@@ -126,11 +128,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func show(for application: Application, relativeTo anchor: NSView) { func show(for application: Application, relativeTo anchor: NSView) {
show(title: application.name, relativeTo: anchor, show(title: application.name, relativeTo: anchor,
available: application.command == nil ? Array(entries.indices) : [0, 1, 3]) available: application.command == nil ? Array(0..<6) : [0, 1, 3])
} }
func showEmoji(named name: String, relativeTo anchor: NSView) { func showEmoji(named name: String, relativeTo anchor: NSView) {
show(title: name, relativeTo: anchor, available: [0, 1], emoji: true) show(title: name, relativeTo: anchor, available: [0, 6, 1], emoji: true)
} }
private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false) { private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false) {
@@ -140,8 +142,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
_ = view _ = view
applicationLabel.stringValue = title applicationLabel.stringValue = title
availableEntries = available availableEntries = available
entries[0].button.title = emoji ? "Copy Emoji" : "Open" let openTitle = emoji ? "Copy and Paste" : "Open"
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.doc" : "arrow.up.forward.app", accessibilityDescription: nil) entries[0].button.title = openTitle
entries[0].button.setAccessibilityLabel(openTitle)
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil)
search.setAccessibilityLabel(emoji ? "Search emoji actions" : "Search application actions") search.setAccessibilityLabel(emoji ? "Search emoji actions" : "Search application actions")
let favorite = entries[1] let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
@@ -188,8 +192,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
} }
func close() { func close(animated: Bool = true) {
guard isShown else { return } guard viewIfLoaded?.superview != nil, !isClosing || !animated else { return }
transitionGeneration += 1 transitionGeneration += 1
let generation = transitionGeneration let generation = transitionGeneration
isClosing = true isClosing = true
@@ -198,13 +202,23 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) } if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) }
mouseMonitor = nil mouseMonitor = nil
coveredPill?.setAccessibilityHidden(false) coveredPill?.setAccessibilityHidden(false)
if !animated {
view.removeFromSuperview()
coveredPill?.layer?.removeAllAnimations()
coveredPill?.alphaValue = coveredPillAlpha
coveredPill = nil
isClosing = false
closed?()
return
}
// Finish teardown even if the presenting Command releases this controller.
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.12 context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.12
view.animator().alphaValue = 0 view.animator().alphaValue = 0
coveredPill?.animator().alphaValue = coveredPillAlpha coveredPill?.animator().alphaValue = coveredPillAlpha
} completionHandler: { [weak self] in } completionHandler: { [self] in
MainActor.assumeIsolated { MainActor.assumeIsolated {
guard let self, self.transitionGeneration == generation, self.isClosing else { return } guard self.transitionGeneration == generation, self.isClosing else { return }
self.view.removeFromSuperview() self.view.removeFromSuperview()
self.coveredPill = nil self.coveredPill = nil
self.isClosing = false self.isClosing = false
@@ -215,9 +229,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visibleEntries = entries.indices.filter { visibleEntries = availableEntries.filter {
availableEntries.contains($0) query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
&& (query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil)
} }
selectedEntry = visibleEntries.first selectedEntry = visibleEntries.first
for index in entries.indices { for index in entries.indices {
@@ -278,6 +291,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
case 3: resetRanking?() case 3: resetRanking?()
case 4: quit?() case 4: quit?()
case 5: uninstall?() case 5: uninstall?()
case 6: copyEmoji?()
default: break default: break
} }
} }
+7 -2
View File
@@ -98,7 +98,13 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
case .command(let command): self?.launcher.toggle(command: command) case .command(let command): self?.launcher.toggle(command: command)
} }
} }
private lazy var settings = SettingsWindowController(shortcuts: shortcuts) private lazy var settings = makeSettings()
private func makeSettings() -> SettingsWindowController {
let controller = SettingsWindowController(shortcuts: shortcuts)
controller.retryUninstallRecovery = { [uninstaller] in await uninstaller.recover() }
return controller
}
private var startupShortcutError: String? private var startupShortcutError: String?
private var startupRecoveryMessage: String? private var startupRecoveryMessage: String?
@@ -117,7 +123,6 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
quitItem.target = NSApplication.shared quitItem.target = NSApplication.shared
launcher.window?.contentView?.menu = menu launcher.window?.contentView?.menu = menu
let loginLaunch = isLoginLaunch let loginLaunch = isLoginLaunch
settings.retryUninstallRecovery = { [uninstaller] in await uninstaller.recover() }
Task { Task {
let report = await uninstaller.recover() let report = await uninstaller.recover()
guard !report.messages.isEmpty else { return } guard !report.messages.isEmpty else { return }
+15 -10
View File
@@ -7,7 +7,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
private let category = NSPopUpButton() private let category = NSPopUpButton()
private let status = NSTextField(labelWithString: "") private let status = NSTextField(labelWithString: "")
private let empty = NSTextField(labelWithString: "No matching emoji") private let empty = NSTextField(labelWithString: "No matching emoji")
private let copyButton = NSButton(title: "Copy Emoji", target: nil, action: nil) private let copyButton = NSButton(title: "Copy and Paste", target: nil, action: nil)
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil) private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
private let actions = ApplicationActionsController() private let actions = ApplicationActionsController()
private var favoriteCount = 0 private var favoriteCount = 0
@@ -19,6 +19,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
var input: (() -> String)? var input: (() -> String)?
var closed: (() -> Void)? var closed: (() -> Void)?
var copied: (() -> Void)? var copied: (() -> Void)?
var pasteRequested: (() -> Void)?
var focusSearch: (() -> Void)? var focusSearch: (() -> Void)?
init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) { init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
@@ -59,7 +60,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
status.lineBreakMode = .byTruncatingTail status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
copyButton.target = self copyButton.target = self
copyButton.action = #selector(copySelection) copyButton.action = #selector(copyAndPasteSelection)
copyButton.keyEquivalent = "\r" copyButton.keyEquivalent = "\r"
copyButton.isBordered = false copyButton.isBordered = false
copyButton.font = .systemFont(ofSize: 12, weight: .medium) copyButton.font = .systemFont(ofSize: 12, weight: .medium)
@@ -109,10 +110,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
@objc private func categoryChanged() { inputChanged() } @objc private func categoryChanged() { inputChanged() }
@objc func goBack() { @objc func goBack() {
if actions.isShown { actions.close() } else { closed?() } if actions.isShown { actions.close() } else { close() }
} }
func close() { func close() {
actions.close() actions.close(animated: false)
closed?() closed?()
} }
override func cancelOperation(_ sender: Any?) { goBack() } override func cancelOperation(_ sender: Any?) { goBack() }
@@ -198,7 +199,8 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
guard results.indices.contains(selected) else { return } guard results.indices.contains(selected) else { return }
let emoji = results[selected] let emoji = results[selected]
actions.isFavorite = (defaults.stringArray(forKey: "favoriteEmoji") ?? []).contains(emoji.value) actions.isFavorite = (defaults.stringArray(forKey: "favoriteEmoji") ?? []).contains(emoji.value)
actions.open = { [weak self] in self?.copySelection() } actions.open = { [weak self] in self?.copyAndPasteSelection() }
actions.copyEmoji = { [weak self] in self?.copySelection() }
actions.toggleFavorite = { [weak self] in self?.toggleFavorite() } actions.toggleFavorite = { [weak self] in self?.toggleFavorite() }
actions.showEmoji(named: emoji.name, relativeTo: actionsButton) actions.showEmoji(named: emoji.name, relativeTo: actionsButton)
copyButton.keyEquivalent = actions.isShown ? "" : "\r" copyButton.keyEquivalent = actions.isShown ? "" : "\r"
@@ -220,7 +222,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1) case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1)
case #selector(NSResponder.moveUp(_:)): moveVertically(-1) case #selector(NSResponder.moveUp(_:)): moveVertically(-1)
case #selector(NSResponder.moveDown(_:)): moveVertically(1) case #selector(NSResponder.moveDown(_:)): moveVertically(1)
case #selector(NSResponder.insertNewline(_:)): copySelection() case #selector(NSResponder.insertNewline(_:)): copyAndPasteSelection()
case #selector(NSResponder.cancelOperation(_:)): goBack() case #selector(NSResponder.cancelOperation(_:)): goBack()
default: return false default: return false
} }
@@ -238,7 +240,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
(collection.item(at: indexPath(for: selected)) as? EmojiItem)?.highlight(true) (collection.item(at: indexPath(for: selected)) as? EmojiItem)?.highlight(true)
} }
@objc func copySelection() { @objc func copySelection() { copySelection(pasting: false) }
@objc func copyAndPasteSelection() { copySelection(pasting: true) }
private func copySelection(pasting: Bool) {
guard results.indices.contains(selected) else { return } guard results.indices.contains(selected) else { return }
let value = results[selected].value let value = results[selected].value
pasteboard.clearContents() pasteboard.clearContents()
@@ -249,8 +254,8 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
var recent = defaults.stringArray(forKey: "recentEmoji") ?? [] var recent = defaults.stringArray(forKey: "recentEmoji") ?? []
recent.removeAll { $0 == value } recent.removeAll { $0 == value }
defaults.set(Array(([value] + recent).prefix(30)), forKey: "recentEmoji") defaults.set(Array(([value] + recent).prefix(30)), forKey: "recentEmoji")
closed?() actions.close(animated: false)
copied?() if pasting { pasteRequested?() } else { copied?() }
} }
func numberOfSections(in collectionView: NSCollectionView) -> Int { splitFavorites ? 2 : 1 } func numberOfSections(in collectionView: NSCollectionView) -> Int { splitFavorites ? 2 : 1 }
@@ -273,7 +278,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
item.highlight(index == selected) item.highlight(index == selected)
item.clicked = { [weak self] in item.clicked = { [weak self] in
self?.selected = index self?.selected = index
self?.copySelection() self?.copyAndPasteSelection()
} }
return item return item
} }
+46 -71
View File
@@ -1,6 +1,7 @@
import AppKit import AppKit
import ConjureKit import ConjureKit
import CoreText import CoreText
import ApplicationServices
@MainActor @MainActor
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
@@ -13,15 +14,13 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private let sectionLabel = NSTextField(labelWithString: "Applications") private let sectionLabel = NSTextField(labelWithString: "Applications")
private let openButton = NSButton(title: "Open Application", target: nil, action: nil) private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
private let history: LaunchHistory private let history: LaunchHistory
private let defaults: UserDefaults
private let pasteboard: NSPasteboard
private var applications: [Application] = Application.commands private var applications: [Application] = Application.commands
private var results: [Application] = [] private var results: [Application] = []
private var calculation: CalculatorResult? private var calculation: CalculatorResult?
private var isCurrencyCalculation = false private var isCurrencyCalculation = false
private var currencyRates: CurrencyRates? private let currency = CurrencyCalculation()
private var currencyFetchedAt: Date?
private var currencyTask: Task<Void, Never>?
private var currencyError: String?
private var currencyFailureQuery: String?
private var locationLabels: [URL: String] = [:] private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>? private var discoveryTask: Task<Void, Never>?
private var isLaunching = false private var isLaunching = false
@@ -52,9 +51,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
}() }()
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) { init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller(),
defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
self.history = history self.history = history
self.uninstaller = uninstaller self.uninstaller = uninstaller
self.defaults = defaults
self.pasteboard = pasteboard
let window = LauncherPanel( let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless], styleMask: [.borderless],
@@ -62,6 +64,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
defer: false defer: false
) )
window.title = "Conjure" window.title = "Conjure"
window.animationBehavior = .none
window.appearance = NSAppearance(named: .darkAqua) window.appearance = NSAppearance(named: .darkAqua)
window.backgroundColor = .clear window.backgroundColor = .clear
window.isOpaque = false window.isOpaque = false
@@ -104,6 +107,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) } if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
else { self.dismiss(restoringFocus: false) } else { self.dismiss(restoringFocus: false) }
} }
currency.changed = { [weak self] in self?.updateResults(preservingSelection: true) }
restorePosition() restorePosition()
} }
@@ -111,10 +115,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
fatalError("Conjure creates its window programmatically") fatalError("Conjure creates its window programmatically")
} }
deinit {
currencyTask?.cancel()
}
func present(command: Application.Command? = nil) { func present(command: Application.Command? = nil) {
guard !isLaunching, window?.attachedSheet == nil else { return } guard !isLaunching, window?.attachedSheet == nil else { return }
if launcherFrameBeforeReview != nil { if launcherFrameBeforeReview != nil {
@@ -123,7 +123,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return return
} }
closeCommand() closeCommand()
actions.close() actions.close(animated: false)
(window as? LauncherPanel)?.beginNumberHintSession() (window as? LauncherPanel)?.beginNumberHintSession()
let frontmost = NSWorkspace.shared.frontmostApplication let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier { if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
@@ -150,8 +150,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard !isLaunching, window?.attachedSheet == nil else { return } guard !isLaunching, window?.attachedSheet == nil else { return }
if window?.isVisible == true, window?.isKeyWindow == true, if window?.isVisible == true, window?.isKeyWindow == true,
launcherFrameBeforeReview == nil, activeCommand == command { launcherFrameBeforeReview == nil, activeCommand == command {
closeCommand()
actions.close()
dismiss() dismiss()
} else { } else {
present(command: command) present(command: command)
@@ -161,12 +159,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) { func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return } guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
if let review = uninstallReview { review.goBack(); return } if let review = uninstallReview { review.goBack(); return }
if isCommandOpen { closeCommand()
goBackCommand()
if restoringFocus { return }
}
guard launcherFrameBeforeReview == nil else { return } guard launcherFrameBeforeReview == nil else { return }
actions.close() actions.close(animated: false)
alignmentGuides?.end() alignmentGuides?.end()
let previous = previousApplication let previous = previousApplication
previousApplication = nil previousApplication = nil
@@ -413,28 +408,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
} }
private func refreshCurrencyRates() {
guard currencyTask == nil else { return }
currencyTask = Task { [weak self] in
let result: Result<CurrencyRates, any Error>
do { result = .success(try await CurrencyAPI.fetch()) }
catch { result = .failure(error) }
guard !Task.isCancelled, let self else { return }
self.currencyTask = nil
switch result {
case .success(let rates):
self.currencyRates = rates
self.currencyFetchedAt = Date()
self.currencyError = nil
self.currencyFailureQuery = nil
case .failure(let error):
self.currencyError = error.localizedDescription
self.currencyFailureQuery = self.searchField.stringValue
}
self.updateResults(preservingSelection: true, allowingCurrencyFetch: false)
}
}
private func updateLocationLabels() { private func updateLocationLabels() {
var nameCounts: [String: Int] = [:] var nameCounts: [String: Int] = [:]
nameCounts.reserveCapacity(applications.count) nameCounts.reserveCapacity(applications.count)
@@ -457,38 +430,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
} }
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) { private func updateResults(preservingSelection: Bool = false) {
guard !isCommandOpen else { return } guard !isCommandOpen else { return }
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow) let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil ? results[tableView.selectedRow].id : nil
let query = searchField.stringValue let query = searchField.stringValue
if currencyFailureQuery != query { currency.update(query)
currencyError = nil isCurrencyCalculation = currency.isCurrency
currencyFailureQuery = nil calculation = currency.result
}
isCurrencyCalculation = Calculator.isCurrencyQuery(query, currencyRates: currencyRates)
let needsCurrencyRefresh = isCurrencyCalculation && (currencyFetchedAt.map {
!Calendar.current.isDate($0, inSameDayAs: Date())
} ?? true)
if allowingCurrencyFetch, needsCurrencyRefresh, currencyError == nil {
refreshCurrencyRates()
}
calculation = Calculator.evaluate(query, currencyRates: currencyRates)
if isCurrencyCalculation, let currencyError {
let cached = currencyRates.map { " Cached snapshot \($0.date) is not used." } ?? ""
calculation = CalculatorResult(
displayValue: "Currency rates unavailable", copyValue: nil,
detail: "Daily rates: \(currencyError). Change the query to retry.\(cached)"
)
} else if isCurrencyCalculation, currencyRates != nil, let answer = calculation {
let cacheStatus = needsCurrencyRefresh
? (currencyTask == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…")
: "Cached daily rates"
calculation = CalculatorResult(
displayValue: answer.displayValue, copyValue: answer.copyValue,
detail: "\(answer.detail) · \(cacheStatus)"
)
}
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() } if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
if calculation != nil { if calculation != nil {
results = [] results = []
@@ -763,7 +712,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return } guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let calculation { if let calculation {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return } guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents() pasteboard.clearContents()
if pasteboard.setString(value, forType: .string) { if pasteboard.setString(value, forType: .string) {
dismiss() dismiss()
@@ -812,6 +760,32 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
} }
private func pasteEmoji() {
let target = previousApplication
let changeCount = pasteboard.changeCount
dismiss()
guard let target, !target.isTerminated,
target.processIdentifier != ProcessInfo.processInfo.processIdentifier,
AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary)
else { return }
Task { [weak self] in
// Wait for focus restoration; never paste into a different app or a reopened launcher.
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(25))
guard let self, self.window?.isVisible == false, !target.isTerminated,
self.pasteboard.changeCount == changeCount else { return }
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == target.processIdentifier else { continue }
guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: true),
let up = CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: false) else { return }
down.flags = .maskCommand
up.flags = .maskCommand
down.postToPid(target.processIdentifier)
up.postToPid(target.processIdentifier)
return
}
}
}
@objc private func goBackCommand() { @objc private func goBackCommand() {
translation?.goBack() translation?.goBack()
emojiPicker?.goBack() emojiPicker?.goBack()
@@ -837,11 +811,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
placeholder = "Enter English text to translate…" placeholder = "Enter English text to translate…"
accessibilityLabel = "English text to translate into Japanese" accessibilityLabel = "English text to translate into Japanese"
case .emoji: case .emoji:
let tool = EmojiPickerController() let tool = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
emojiPicker = tool emojiPicker = tool
tool.input = { [weak self] in self?.searchField.stringValue ?? "" } tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) } tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) }
tool.copied = { [weak self] in self?.dismiss() } tool.copied = { [weak self] in self?.dismiss() }
tool.pasteRequested = { [weak self] in self?.pasteEmoji() }
controller = tool controller = tool
placeholder = "Search emoji by name or :shortcode:…" placeholder = "Search emoji by name or :shortcode:…"
accessibilityLabel = "Search emoji" accessibilityLabel = "Search emoji"
@@ -871,7 +846,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
translation?.closed = close translation?.closed = close
emojiPicker?.closed = close emojiPicker?.closed = close
actions.close() actions.close(animated: false)
(window as? LauncherPanel)?.resetNumberHints() (window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = "" openButton.keyEquivalent = ""
searchSymbol.isHidden = true searchSymbol.isHidden = true
+8 -18
View File
@@ -249,6 +249,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refresh() { private func refresh() {
refreshLogin() refreshLogin()
recorder.isRecording = shortcuts.isRecording
commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0 commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0
recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut") recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut")
let name = shortcut.shortcut?.displayName let name = shortcut.shortcut?.displayName
@@ -266,10 +267,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
} }
@objc private func beginRecording() { @objc private func beginRecording() {
guard !recorder.isRecording else { return } guard !shortcuts.isRecording else { return }
errorLabel.stringValue = "" errorLabel.stringValue = ""
shortcuts.suspend() shortcuts.beginRecording()
recorder.isRecording = true
refresh() refresh()
if window?.makeFirstResponder(recorder) != true { if window?.makeFirstResponder(recorder) != true {
cancelRecording() cancelRecording()
@@ -283,9 +283,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
return return
} }
guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else { guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else {
recorder.isRecording = false
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut." errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
restoreSavedShortcuts() appendShortcutErrors(shortcuts.cancelRecording())
refresh() refresh()
return return
} }
@@ -293,28 +292,19 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
} }
private func apply(_ candidate: KeyboardShortcut?) { private func apply(_ candidate: KeyboardShortcut?) {
let wasRecording = recorder.isRecording
recorder.isRecording = false
errorLabel.stringValue = "" errorLabel.stringValue = ""
do { appendShortcutErrors(shortcuts.finishRecording(candidate, at: destinationSelector.indexOfSelectedItem))
try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem)
} catch {
errorLabel.stringValue = error.localizedDescription
}
if wasRecording { restoreSavedShortcuts() }
refresh() refresh()
} }
private func cancelRecording() { private func cancelRecording() {
guard recorder.isRecording else { return } guard shortcuts.isRecording else { return }
recorder.isRecording = false
errorLabel.stringValue = "" errorLabel.stringValue = ""
restoreSavedShortcuts() appendShortcutErrors(shortcuts.cancelRecording())
refresh() refresh()
} }
private func restoreSavedShortcuts() { private func appendShortcutErrors(_ errors: [String]) {
let errors = shortcuts.start()
if !errors.isEmpty { if !errors.isEmpty {
let message = errors.joined(separator: "\n") let message = errors.joined(separator: "\n")
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)" errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
+95 -41
View File
@@ -15,11 +15,25 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
var input: (() -> String)? var input: (() -> String)?
var closed: (() -> Void)? var closed: (() -> Void)?
private static let credential: [String: Any] = [ private let defaults: UserDefaults
kSecClass as String: kSecClassGenericPassword, private let credentialsStore: Credentials
kSecAttrService as String: "Conjure.OpenAI", private let myMemory: @MainActor (String) async throws -> String
kSecAttrAccount as String: "translation", private let openAI: @MainActor (String, String) async throws -> String
]
init(
defaults: UserDefaults = .standard,
credentials: Credentials = .keychain,
myMemory: @escaping @MainActor (String) async throws -> String = { try await MyMemoryAPI.translate($0) },
openAI: @escaping @MainActor (String, String) async throws -> String = { try await TranslationAPI.translate($0, apiKey: $1) }
) {
self.defaults = defaults
self.credentialsStore = credentials
self.myMemory = myMemory
self.openAI = openAI
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("Conjure creates Translate programmatically") }
override func loadView() { override func loadView() {
view = NSView() view = NSView()
@@ -27,7 +41,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
title.font = .systemFont(ofSize: 13, weight: .semibold) title.font = .systemFont(ofSize: 13, weight: .semibold)
title.textColor = SunsetPalette.text title.textColor = SunsetPalette.text
provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0) provider.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0)
provider.setAccessibilityLabel("Translation provider") provider.setAccessibilityLabel("Translation provider")
provider.target = self provider.target = self
provider.action = #selector(providerChanged) provider.action = #selector(providerChanged)
@@ -132,21 +146,18 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
func controlTextDidChange(_ notification: Notification) { inputChanged() } func controlTextDidChange(_ notification: Notification) { inputChanged() }
@objc func goBack() { @objc func goBack() {
task?.cancel() cancelRequest()
task = nil
closed?() closed?()
} }
func inputChanged() { func inputChanged() {
task?.cancel() cancelRequest()
task = nil
translateButton.isEnabled = true
output.string = "" output.string = ""
setStatus("Press Return to translate.") setStatus("Press Return to translate.")
} }
@objc private func providerChanged() { @objc private func providerChanged() {
UserDefaults.standard.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider") defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider")
keyField.stringValue = "" keyField.stringValue = ""
updateProvider() updateProvider()
} }
@@ -165,33 +176,26 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
setStatus("Enter a valid API key before saving.", isError: true) setStatus("Enter a valid API key before saving.", isError: true)
return return
} }
let data = Data(key.utf8) do {
var result = SecItemUpdate(Self.credential as CFDictionary, [kSecValueData as String: data] as CFDictionary) try credentialsStore.save(key)
if result == errSecItemNotFound {
var item = Self.credential
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
result = SecItemAdd(item as CFDictionary, nil)
}
if result == errSecSuccess {
keyField.stringValue = "" keyField.stringValue = ""
setStatus("API key saved in Keychain. Press Return to translate.") setStatus("API key saved in Keychain. Press Return to translate.")
} else { showKeychainError(result) } } catch { setStatus(error.localizedDescription, isError: true) }
} }
@objc private func forgetKey() { @objc private func forgetKey() {
cancelRequest()
do {
try credentialsStore.forget()
keyField.stringValue = ""
setStatus("API key removed.")
} catch { setStatus(error.localizedDescription, isError: true) }
}
private func cancelRequest() {
task?.cancel() task?.cancel()
task = nil task = nil
translateButton.isEnabled = true translateButton.isEnabled = true
let result = SecItemDelete(Self.credential as CFDictionary)
if result == errSecSuccess || result == errSecItemNotFound {
keyField.stringValue = ""
setStatus("API key removed.")
} else { showKeychainError(result) }
}
private func showKeychainError(_ code: OSStatus) {
setStatus("Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))", isError: true)
} }
private func setStatus(_ message: String, isError: Bool = false) { private func setStatus(_ message: String, isError: Bool = false) {
@@ -210,25 +214,27 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
var key = "" var key = ""
if !myMemory { if !myMemory {
if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } } if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } }
var query = Self.credential do {
query[kSecReturnData as String] = true guard let savedKey = try credentialsStore.load() else {
var item: CFTypeRef? setStatus("Enter and save your OpenAI API key first.", isError: true)
let result = SecItemCopyMatching(query as CFDictionary, &item) return
guard result == errSecSuccess, let data = item as? Data, let savedKey = String(data: data, encoding: .utf8) else { }
if result == errSecItemNotFound { setStatus("Enter and save your OpenAI API key first.", isError: true) } key = savedKey
else { showKeychainError(result) } } catch {
setStatus(error.localizedDescription, isError: true)
return return
} }
key = savedKey
} }
output.string = "" output.string = ""
setStatus("Translating…") setStatus("Translating…")
translateButton.isEnabled = false translateButton.isEnabled = false
let myMemoryTranslate = self.myMemory
let openAITranslate = self.openAI
task = Task { [weak self] in task = Task { [weak self] in
do { do {
let translation = try await myMemory let translation = try await myMemory
? MyMemoryAPI.translate(text) ? myMemoryTranslate(text)
: TranslationAPI.translate(text, apiKey: key) : openAITranslate(text, key)
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.output.string = translation self.output.string = translation
self.setStatus("Japanese · Select text to copy (⌘C).") self.setStatus("Japanese · Select text to copy (⌘C).")
@@ -241,4 +247,52 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
self.translateButton.isEnabled = true self.translateButton.isEnabled = true
} }
} }
@MainActor
struct Credentials {
var save: (String) throws -> Void
var load: () throws -> String?
var forget: () throws -> Void
private static var query: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "Conjure.OpenAI",
kSecAttrAccount as String: "translation"]
}
static let keychain = Credentials(
save: { key in
let data = Data(key.utf8)
var result = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if result == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
result = SecItemAdd(item as CFDictionary, nil)
}
guard result == errSecSuccess else { throw KeychainError(code: result) }
},
load: {
var query = Self.query
query[kSecReturnData as String] = true
var item: CFTypeRef?
let result = SecItemCopyMatching(query as CFDictionary, &item)
if result == errSecItemNotFound { return nil }
guard result == errSecSuccess, let data = item as? Data,
let key = String(data: data, encoding: .utf8) else { throw KeychainError(code: result) }
return key
},
forget: {
let result = SecItemDelete(query as CFDictionary)
guard result == errSecSuccess || result == errSecItemNotFound else { throw KeychainError(code: result) }
}
)
}
private struct KeychainError: LocalizedError {
let code: OSStatus
var errorDescription: String? {
"Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))"
}
}
} }
@@ -0,0 +1,81 @@
import Foundation
/// Owns shared daily rates and calculation state for one launcher lifetime.
@MainActor
public final class CurrencyCalculation {
public private(set) var result: CalculatorResult?
public private(set) var isCurrency = false
public var changed: (() -> Void)?
private let fetch: @MainActor () async throws -> CurrencyRates
private let now: () -> Date
private let calendar: Calendar
private var query = ""
private var rates: CurrencyRates?
private var fetchedAt: Date?
private var task: Task<Void, Never>?
private var error: String?
private var failureQuery: String?
public init(
now: @escaping () -> Date = Date.init,
calendar: Calendar = .current,
fetch: @escaping @MainActor () async throws -> CurrencyRates = { try await CurrencyAPI.fetch() }
) {
self.now = now
self.calendar = calendar
self.fetch = fetch
}
deinit { task?.cancel() }
public func update(_ query: String) {
self.query = query
if failureQuery != query {
error = nil
failureQuery = nil
}
evaluate(allowingFetch: true)
}
private func evaluate(allowingFetch: Bool) {
isCurrency = Calculator.isCurrencyQuery(query, currencyRates: rates)
let stale = fetchedAt.map { !calendar.isDate($0, inSameDayAs: now()) } ?? true
if allowingFetch, isCurrency, stale, error == nil, task == nil { refresh() }
result = Calculator.evaluate(query, currencyRates: rates)
if isCurrency, let error {
let cached = rates.map { " Cached snapshot \($0.date) is not used." } ?? ""
result = CalculatorResult(displayValue: "Currency rates unavailable", copyValue: nil,
detail: "Daily rates: \(error). Change the query to retry.\(cached)")
} else if isCurrency, rates != nil, let answer = result {
let cacheStatus = stale
? (task == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…")
: "Cached daily rates"
result = CalculatorResult(displayValue: answer.displayValue, copyValue: answer.copyValue,
detail: "\(answer.detail) · \(cacheStatus)")
}
}
private func refresh() {
let fetch = self.fetch
task = Task { [weak self] in
let response: Result<CurrencyRates, any Error>
do { response = .success(try await fetch()) }
catch { response = .failure(error) }
guard !Task.isCancelled, let self else { return }
self.task = nil
switch response {
case .success(let rates):
self.rates = rates
self.fetchedAt = self.now()
self.error = nil
self.failureQuery = nil
case .failure(let error):
self.error = error.localizedDescription
self.failureQuery = self.query
}
self.evaluate(allowingFetch: false)
self.changed?()
}
}
}
+23 -27
View File
@@ -10,6 +10,18 @@ public final class GlobalShortcut {
private let defaults: UserDefaults private let defaults: UserDefaults
private let action: @MainActor () -> Void private let action: @MainActor () -> Void
private let native = NativeHotKey() private let native = NativeHotKey()
private var registration: Registration?
struct Registration {
var activate: (KeyboardShortcut) throws -> Void
var unregister: () throws -> Void
var stop: () -> Void
}
convenience init(defaults: UserDefaults, storageKey: String, registration: Registration) {
self.init(defaults: defaults, storageKey: storageKey, action: {})
self.registration = registration
}
public init( public init(
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
@@ -44,7 +56,7 @@ public final class GlobalShortcut {
try activate(candidate) try activate(candidate)
defaults.set(candidate.storedValue, forKey: storageKey) defaults.set(candidate.storedValue, forKey: storageKey)
} else { } else {
try native.unregister() try unregister()
isRegistered = false isRegistered = false
defaults.removeObject(forKey: storageKey) defaults.removeObject(forKey: storageKey)
} }
@@ -53,7 +65,7 @@ public final class GlobalShortcut {
public func suspend() { public func suspend() {
do { do {
try native.unregister() try unregister()
isRegistered = false isRegistered = false
} catch { } catch {
NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription) NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription)
@@ -61,7 +73,7 @@ public final class GlobalShortcut {
} }
public func stop() { public func stop() {
native.dispose() if let registration { registration.stop() } else { native.dispose() }
isRegistered = false isRegistered = false
} }
@@ -69,36 +81,22 @@ public final class GlobalShortcut {
action() action()
} }
private func activate(_ candidate: KeyboardShortcut) throws { private func unregister() throws {
try Self.rejectSystemConflict(candidate) if let registration { try registration.unregister() } else { try native.unregister() }
try native.register(candidate)
isRegistered = true
} }
private static func rejectSystemConflict(_ candidate: KeyboardShortcut) throws { private func activate(_ candidate: KeyboardShortcut) throws {
var copied: Unmanaged<CFArray>? if let registration {
let status = CopySymbolicHotKeys(&copied) try registration.activate(candidate)
let result = copied?.takeRetainedValue() } else {
guard status == noErr, let result else { try native.register(candidate)
throw ShortcutError.native(operation: "read macOS keyboard shortcuts", status: status)
}
let hotKeys = result as NSArray
for case let record as NSDictionary in hotKeys {
guard let enabled = record[kHISymbolicHotKeyEnabled] as? NSNumber, enabled.boolValue,
let code = record[kHISymbolicHotKeyCode] as? NSNumber,
let modifiers = record[kHISymbolicHotKeyModifiers] as? NSNumber else { continue }
let relevantModifiers = UInt32(cmdKey | optionKey | controlKey | shiftKey)
if code.uint16Value == candidate.keyCode,
modifiers.uint32Value & relevantModifiers == candidate.carbonModifiers {
throw ShortcutError.systemConflict(candidate.displayName)
}
} }
isRegistered = true
} }
} }
enum ShortcutError: LocalizedError { enum ShortcutError: LocalizedError {
case invalidSavedShortcut case invalidSavedShortcut
case systemConflict(String)
case inAppConflict(shortcut: String, action: String) case inAppConflict(shortcut: String, action: String)
case duplicate(shortcut: String, destination: String) case duplicate(shortcut: String, destination: String)
case native(operation: String, status: OSStatus) case native(operation: String, status: OSStatus)
@@ -107,8 +105,6 @@ enum ShortcutError: LocalizedError {
switch self { switch self {
case .invalidSavedShortcut: case .invalidSavedShortcut:
return "The saved shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings." return "The saved shortcut is invalid. Choose a new shortcut or clear it in Conjure Settings."
case .systemConflict(let shortcut):
return "\(shortcut) is already an enabled macOS shortcut. Check System Settings → Keyboard → Keyboard Shortcuts, including Spotlight and Input Sources. On macOS 27, Command-Shift-Space is also reserved for Siri AI screen capture. Choose another shortcut or change the system binding; Conjure will not change your system settings."
case .inAppConflict(let shortcut, let action): case .inAppConflict(let shortcut, let action):
return "\(shortcut) is already used by Conjure for \(action). Choose another shortcut." return "\(shortcut) is already used by Conjure for \(action). Choose another shortcut."
case .duplicate(let shortcut, let destination): case .duplicate(let shortcut, let destination):
+20 -1
View File
@@ -27,6 +27,13 @@ public final class GlobalShortcuts {
public let bindings: [GlobalShortcut] public let bindings: [GlobalShortcut]
private let defaults: UserDefaults private let defaults: UserDefaults
private var isSuspended = false private var isSuspended = false
public var isRecording: Bool { isSuspended }
init(defaults: UserDefaults, bindings: [GlobalShortcut]) {
precondition(bindings.count == Self.destinations.count)
self.defaults = defaults
self.bindings = bindings
}
public init( public init(
defaults: UserDefaults = .standard, defaults: UserDefaults = .standard,
@@ -60,11 +67,23 @@ public final class GlobalShortcuts {
return errors return errors
} }
public func suspend() { public func beginRecording() {
isSuspended = true isSuspended = true
bindings.forEach { $0.suspend() } bindings.forEach { $0.suspend() }
} }
public func cancelRecording() -> [String] {
guard isRecording else { return [] }
return start()
}
public func finishRecording(_ candidate: KeyboardShortcut?, at index: Int) -> [String] {
var errors: [String] = []
do { try setShortcut(candidate, at: index) }
catch { errors.append(error.localizedDescription) }
return errors + cancelRecording()
}
public func stop() { public func stop() {
isSuspended = false isSuspended = false
bindings.forEach { $0.stop() } bindings.forEach { $0.stop() }
+1 -5
View File
@@ -1,7 +1,3 @@
- [x] add an emoji picker inspired raycast's emoji picker - [x] tighten Command presentation lifetime contracts: guarantee complete Command-owned Actions teardown and distinguish one-level Back from full dismissal or destination replacement. Fixed in the existing controllers without extracting another presentation module. Covered nested Escape, query restoration, replacement with Actions open, and copy/paste dismissal.
- [x] make the star/favorite symbol a bit smaller when prefixed launcher list entries (e.g. "<star> Application", or "<star> Command")
- [x] currently the top fade (from the top section/overlay, typically some search input) overshadows the top of main content of the window.
- spacing should be added such that the fade doesn't overlay the content.
- of course, if the user scrolls some scrollable content, the content would (and should) naturally be overshadowed.
- [ ] add a notes app inspired by raycast notes - [ ] add a notes app inspired by raycast notes
- [ ] improve on the conjure translation command - [ ] improve on the conjure translation command
@@ -0,0 +1,74 @@
import ConjureKit
import Foundation
import Testing
@MainActor
private final class RateRequests {
var pending: [CheckedContinuation<CurrencyRates, any Error>] = []
func fetch() async throws -> CurrencyRates {
try await withCheckedThrowingContinuation { pending.append($0) }
}
}
@MainActor
private func waitForRates(_ condition: () -> Bool) async throws {
// Scheduling deadline only; calculation freshness uses the injected clock.
for _ in 0..<1_000 {
if condition() { return }
try await Task.sleep(for: .milliseconds(1))
}
try #require(condition(), "Currency calculation did not reach the expected state")
}
@Test @MainActor
func currencyLifetimeSharesRefreshAndPreservesStaleCopyUntilFailure() async throws {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
var now = calendar.date(from: DateComponents(year: 2026, month: 1, day: 2, hour: 12))!
let requests = RateRequests()
let calculation = CurrencyCalculation(now: { now }, calendar: calendar, fetch: { try await requests.fetch() })
var completions = 0
calculation.changed = { completions += 1 }
let snapshot = CurrencyRates(date: "2026-01-02", rates: ["eur": 1, "usd": 2, "dkk": 8])
calculation.update("100 USD to DKK")
#expect(calculation.isCurrency)
#expect(calculation.result?.copyValue == nil)
try await waitForRates { requests.pending.count == 1 }
calculation.update("50 USD to DKK")
requests.pending[0].resume(returning: snapshot)
try await waitForRates { completions == 1 }
#expect(calculation.result?.copyValue == "200 DKK")
calculation.update("100 USD to DKK")
#expect(calculation.result?.copyValue == "400 DKK")
#expect(requests.pending.count == 1)
now = calendar.date(byAdding: .day, value: 1, to: now)!
calculation.update("100 USD to DKK")
#expect(calculation.result?.copyValue == "400 DKK")
#expect(calculation.result?.detail.contains("Stale cache; refreshing") == true)
try await waitForRates { requests.pending.count == 2 }
calculation.update("50 USD to DKK")
requests.pending[1].resume(throwing: URLError(.notConnectedToInternet))
try await waitForRates { completions == 2 }
#expect(calculation.result?.copyValue == nil)
#expect(calculation.result?.displayValue == "Currency rates unavailable")
calculation.update("50 USD to DKK")
#expect(calculation.result?.copyValue == nil)
#expect(requests.pending.count == 2)
calculation.update("25 USD to DKK")
try await waitForRates { requests.pending.count == 3 }
calculation.update("Safari") // Leaving currency does not cancel shared work.
#expect(!calculation.isCurrency)
#expect(calculation.result == nil)
requests.pending[2].resume(returning: snapshot)
try await waitForRates { completions == 3 }
#expect(calculation.result == nil)
calculation.update("25 USD to DKK")
#expect(calculation.result?.copyValue == "100 DKK")
#expect(requests.pending.count == 3) // Fetch day, not snapshot metadata, controls freshness.
calculation.update("1 km to mi")
#expect(!calculation.isCurrency)
#expect(calculation.result?.copyValue == "0.621371192237 mi")
}
+124 -3
View File
@@ -14,27 +14,35 @@ import Testing
} }
var query = "face" var query = "face"
var closed = false var closed = false
var pasteRequests = 0
let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
picker.input = { query } picker.input = { query }
picker.closed = { closed = true } picker.closed = { closed = true }
picker.pasteRequested = {
#expect(pasteboard.string(forType: .string) == EmojiCatalog.search(query)[1].value)
pasteRequests += 1
}
_ = picker.view _ = picker.view
let matches = EmojiCatalog.search(query) let matches = EmojiCatalog.search(query)
#expect(picker.handle(#selector(NSResponder.moveRight(_:)))) #expect(picker.handle(#selector(NSResponder.moveRight(_:))))
#expect(picker.handle(#selector(NSResponder.insertNewline(_:)))) #expect(picker.handle(#selector(NSResponder.insertNewline(_:))))
#expect(pasteboard.string(forType: .string) == matches[1].value) #expect(pasteboard.string(forType: .string) == matches[1].value)
#expect(closed) #expect(!closed)
#expect(pasteRequests == 1)
#expect(defaults.stringArray(forKey: "recentEmoji") == [matches[1].value]) #expect(defaults.stringArray(forKey: "recentEmoji") == [matches[1].value])
closed = false closed = false
query = "no-such-emoji" query = "no-such-emoji"
picker.inputChanged() picker.inputChanged()
picker.copySelection() picker.copyAndPasteSelection()
#expect(!closed) #expect(!closed)
#expect(pasteboard.string(forType: .string) == matches[1].value) #expect(pasteboard.string(forType: .string) == matches[1].value)
#expect(pasteRequests == 1)
query = "👩‍💻" query = "👩‍💻"
picker.inputChanged() picker.inputChanged()
picker.copySelection() picker.copySelection()
#expect(pasteboard.string(forType: .string) == "👩‍💻") #expect(pasteboard.string(forType: .string) == "👩‍💻")
#expect(pasteRequests == 1)
let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
_ = reopened.view _ = reopened.view
@@ -43,13 +51,117 @@ import Testing
#expect(defaults.stringArray(forKey: "recentEmoji") == ["👩‍💻", matches[1].value]) #expect(defaults.stringArray(forKey: "recentEmoji") == ["👩‍💻", matches[1].value])
} }
@Test @MainActor func emojiActionsDefaultToPasteAndKeepCopyOnlyAvailable() throws {
_ = NSApplication.shared
let suite = "ConjureEmojiActions.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let pasteboard = NSPasteboard.withUniqueName()
defer { defaults.removePersistentDomain(forName: suite); pasteboard.releaseGlobally() }
let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
picker.input = { "👩‍💻" }
var pasteRequests = 0
picker.pasteRequested = { pasteRequests += 1 }
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 400),
styleMask: [.borderless], backing: .buffered, defer: false)
window.contentView = picker.view
picker.showActions()
#expect(picker.handle(#selector(NSResponder.insertNewline(_:))))
#expect(pasteboard.string(forType: .string) == "👩‍💻")
#expect(pasteRequests == 1)
pasteboard.clearContents()
picker.showActions()
#expect(picker.handle(#selector(NSResponder.moveDown(_:))))
#expect(picker.handle(#selector(NSResponder.insertNewline(_:))))
#expect(pasteboard.string(forType: .string) == "👩‍💻")
#expect(pasteRequests == 1)
picker.showActions()
#expect(picker.handle(#selector(NSResponder.moveDown(_:))))
#expect(picker.handle(#selector(NSResponder.moveDown(_:))))
#expect(picker.handle(#selector(NSResponder.insertNewline(_:))))
#expect(defaults.stringArray(forKey: "favoriteEmoji") == ["👩‍💻"])
#expect(pasteRequests == 1)
}
@Test @MainActor func closingEmojiRemovesActionsEvenDuringBackAnimation() throws {
_ = NSApplication.shared
let suite = "ConjureEmojiLifetime.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let pasteboard = NSPasteboard.withUniqueName()
defer { defaults.removePersistentDomain(forName: suite); pasteboard.releaseGlobally() }
let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
picker.input = { "👩‍💻" }
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 400),
styleMask: [.borderless], backing: .buffered, defer: false)
let host = try #require(window.contentView)
host.addSubview(picker.view)
picker.showActions()
let overlay = try #require(host.subviews.first { $0 !== picker.view })
picker.goBack()
var closed = false
picker.closed = { closed = true }
picker.close()
#expect(closed)
#expect(overlay.superview == nil)
}
@Test(arguments: ["replace", "dismiss", "copy", "paste"])
@MainActor func commandFullExitRemovesEmojiAndItsActions(_ exit: String) throws {
_ = NSApplication.shared
let suite = "ConjureCommandExit.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let pasteboard = NSPasteboard.withUniqueName()
defer { defaults.removePersistentDomain(forName: suite); pasteboard.releaseGlobally() }
let launcher = LauncherWindowController(
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
defaults: defaults, pasteboard: pasteboard)
let window = try #require(launcher.window)
defer { window.orderOut(nil) }
let content = try #require(window.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "emoji"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
search.stringValue = "👩‍💻"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
window.orderFront(nil)
let button = try #require(descendants(content).compactMap { $0 as? NSButton }.first {
$0.title == "Actions" && $0.target is EmojiPickerController
})
let existing = Set(content.subviews.map(ObjectIdentifier.init))
(button.target as? EmojiPickerController)?.showActions()
let overlay = try #require(content.subviews.last { !existing.contains(ObjectIdentifier($0)) })
switch exit {
case "replace": launcher.present(command: .translate)
case "dismiss": window.performClose(nil)
case "copy": (button.target as? EmojiPickerController)?.copySelection()
default: _ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
}
#expect(overlay.superview == nil)
#expect(!descendants(content).contains { $0 is NSCollectionView })
if exit == "replace" {
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
#expect(search.stringValue.isEmpty)
} else {
#expect(!window.isVisible)
#expect(search.stringValue == "emoji")
#expect(search.accessibilityLabel() == "Search applications, calculate, or convert units and currencies")
}
if exit == "copy" || exit == "paste" {
#expect(pasteboard.string(forType: .string) == "👩‍💻")
#expect(defaults.stringArray(forKey: "recentEmoji") == ["👩‍💻"])
}
}
@Test @MainActor func emojiCommandRestoresLauncherSearchOnEscape() throws { @Test @MainActor func emojiCommandRestoresLauncherSearchOnEscape() throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "ConjureEmojiCommand.\(UUID().uuidString)" let suite = "ConjureEmojiCommand.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }) let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) })
let launcher = LauncherWindowController(history: history) let launcher = LauncherWindowController(history: history, defaults: defaults)
let content = try #require(launcher.window?.contentView) let content = try #require(launcher.window?.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
@@ -61,6 +173,15 @@ import Testing
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
#expect(grid.numberOfItems(inSection: 0) == 1) #expect(grid.numberOfItems(inSection: 0) == 1)
#expect((grid.dataSource?.collectionView(grid, itemForRepresentedObjectAt: IndexPath(item: 0, section: 0)).view as? NSButton)?.title == "👩‍💻") #expect((grid.dataSource?.collectionView(grid, itemForRepresentedObjectAt: IndexPath(item: 0, section: 0)).view as? NSButton)?.title == "👩‍💻")
let actionsButton = try #require(descendants(content).compactMap { $0 as? NSButton }.first {
$0.title == "Actions" && $0.target is EmojiPickerController
})
(actionsButton.target as? EmojiPickerController)?.showActions()
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
#expect(descendants(content).contains { $0 === grid })
#expect(search.stringValue == ":woman_technologist:")
let copyButton = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.title == "Copy and Paste" })
#expect(copyButton.keyEquivalent == "\r")
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:))) _ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
#expect(!descendants(content).contains { $0 === grid }) #expect(!descendants(content).contains { $0 === grid })
#expect(search.stringValue == "emoji") #expect(search.stringValue == "emoji")
@@ -33,7 +33,7 @@ func duplicateGlobalBindingIsRejectedWhileSuspendedWithoutChangingSavedBinding()
defaults.set(candidate.storedValue, forKey: "globalActivationShortcut") defaults.set(candidate.storedValue, forKey: "globalActivationShortcut")
defaults.set(previous.storedValue, forKey: "globalCommandShortcut.emoji") defaults.set(previous.storedValue, forKey: "globalCommandShortcut.emoji")
let shortcuts = GlobalShortcuts(defaults: defaults) { _ in } let shortcuts = GlobalShortcuts(defaults: defaults) { _ in }
shortcuts.suspend() shortcuts.beginRecording()
do { do {
try shortcuts.setShortcut(candidate, at: emojiIndex) try shortcuts.setShortcut(candidate, at: emojiIndex)
@@ -0,0 +1,143 @@
import AppKit
import Foundation
import Testing
@testable import ConjureKit
@testable import Conjure
@MainActor
private final class RecordingRegistration {
var active: KeyboardShortcut?
var rejected: KeyboardShortcut?
var unavailable = false
var adapter: GlobalShortcut.Registration {
.init(activate: { [self] candidate in
if unavailable || candidate == rejected { throw RecordingFailure() }
active = candidate
}, unregister: { [self] in active = nil }, stop: { [self] in active = nil })
}
}
private struct RecordingFailure: LocalizedError {
var errorDescription: String? { "Registration denied" }
}
@Test @MainActor
func recordingOwnsCompletionCancellationAndFailureRestoration() throws {
let suite = "ShortcutRecording.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let old = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.control, .option]))
let candidate = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.command, .shift]))
let other = try #require(KeyboardShortcut(keyCode: 0, modifiers: [.control, .option]))
let states = GlobalShortcuts.destinations.map { _ in RecordingRegistration() }
defaults.set(old.storedValue, forKey: GlobalShortcuts.destinations[0].storageKey)
defaults.set(other.storedValue, forKey: GlobalShortcuts.destinations[1].storageKey)
let bindings = GlobalShortcuts.destinations.enumerated().map { index, destination in
GlobalShortcut(defaults: defaults, storageKey: destination.storageKey, registration: states[index].adapter)
}
let shortcuts = GlobalShortcuts(defaults: defaults, bindings: bindings)
defer { shortcuts.stop() }
#expect(shortcuts.start().isEmpty)
#expect(states[0].active == old)
#expect(states[1].active == other)
shortcuts.beginRecording()
shortcuts.beginRecording() // Beginning twice must not change the saved bindings.
#expect(shortcuts.isRecording)
#expect(states.allSatisfy { $0.active == nil })
#expect(shortcuts.cancelRecording().isEmpty)
#expect(!shortcuts.isRecording)
#expect(states[0].active == old)
#expect(states[1].active == other)
#expect(shortcuts.cancelRecording().isEmpty)
shortcuts.beginRecording()
states[0].rejected = candidate
let failure = shortcuts.finishRecording(candidate, at: 0)
#expect(failure.contains { $0.contains("Registration denied") })
#expect(!shortcuts.isRecording)
#expect(bindings[0].shortcut == old)
#expect(KeyboardShortcut(storedValue: defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey)) == old)
#expect(states[0].active == old)
#expect(states[1].active == other)
shortcuts.beginRecording()
states[0].rejected = nil
#expect(shortcuts.finishRecording(candidate, at: 0).isEmpty)
#expect(!shortcuts.isRecording)
#expect(states[0].active == candidate)
#expect(states[1].active == other)
#expect(KeyboardShortcut(storedValue: defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey)) == candidate)
shortcuts.beginRecording()
#expect(!shortcuts.finishRecording(other, at: 0).isEmpty) // Duplicate: still restore everything.
#expect(states[0].active == candidate)
#expect(states[1].active == other)
#expect(!shortcuts.isRecording)
shortcuts.beginRecording()
states[1].unavailable = true
let restorationFailure = shortcuts.cancelRecording()
#expect(restorationFailure.contains { $0.hasPrefix(GlobalShortcuts.destinations[1].title) })
#expect(!shortcuts.isRecording)
#expect(states[0].active == candidate)
#expect(states[1].active == nil)
#expect(bindings[1].shortcut == other)
#expect(shortcuts.finishRecording(nil, at: 0).isEmpty) // Clear outside recording must not resume other bindings.
#expect(states[0].active == nil)
#expect(states[1].active == nil)
#expect(defaults.object(forKey: GlobalShortcuts.destinations[0].storageKey) == nil)
}
@Test(arguments: ["close", "focus", "destination", "escape", "invalid", "responder"])
@MainActor
func recordingSettingsExitRestoresSavedShortcuts(_ exit: String) throws {
_ = NSApplication.shared
let suite = "RecordingExit.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let shortcut = try #require(KeyboardShortcut(keyCode: 49, modifiers: [.control, .option]))
defaults.set(shortcut.storedValue, forKey: GlobalShortcuts.destinations[0].storageKey)
let states = GlobalShortcuts.destinations.map { _ in RecordingRegistration() }
let bindings = GlobalShortcuts.destinations.enumerated().map { index, destination in
GlobalShortcut(defaults: defaults, storageKey: destination.storageKey, registration: states[index].adapter)
}
let shortcuts = GlobalShortcuts(defaults: defaults, bindings: bindings)
defer { shortcuts.stop() }
#expect(shortcuts.start().isEmpty)
let settings = SettingsWindowController(shortcuts: shortcuts)
let window = try #require(settings.window)
defer { window.close() }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let views = descendants(try #require(window.contentView))
let recorder = try #require(views.compactMap { $0 as? NSButton }.first {
$0.action == NSSelectorFromString("beginRecording")
})
recorder.performClick(nil)
#expect(shortcuts.isRecording)
#expect(states[0].active == nil)
switch exit {
case "close":
settings.windowWillClose(Notification(name: NSWindow.willCloseNotification, object: window))
case "focus":
settings.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification, object: window))
case "destination":
let destination = try #require(views.compactMap { $0 as? NSPopUpButton }.first)
destination.selectItem(at: 1)
_ = settings.perform(destination.action, with: destination)
case "responder":
_ = window.makeFirstResponder(nil)
default:
let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: 1, windowNumber: window.windowNumber, context: nil,
characters: exit == "escape" ? "\u{1b}" : "a",
charactersIgnoringModifiers: exit == "escape" ? "\u{1b}" : "a",
isARepeat: false, keyCode: exit == "escape" ? 53 : 0))
recorder.keyDown(with: event)
}
#expect(!shortcuts.isRecording)
#expect(states[0].active == shortcut)
#expect(bindings[0].isRegistered)
}
@@ -0,0 +1,201 @@
import AppKit
import Foundation
import Testing
@testable import Conjure
@MainActor
private final class TranslationRequests {
var inputs: [(text: String, key: String?)] = []
var pending: [CheckedContinuation<String, any Error>] = []
var returned = 0
// Deliberately ignore cancellation: the controller must reject late completions itself.
func translate(_ text: String, key: String? = nil) async throws -> String {
inputs.append((text, key))
defer { returned += 1 }
return try await withCheckedThrowingContinuation { pending.append($0) }
}
}
private struct CredentialFailure: LocalizedError {
var errorDescription: String? { "Credential access denied" }
}
@MainActor
private final class TranslationKeys {
var value: String?
var accesses = 0
var denied = false
var store: TranslationController.Credentials {
.init(save: { [self] key in
accesses += 1
if denied { throw CredentialFailure() }
value = key
}, load: { [self] in
accesses += 1
if denied { throw CredentialFailure() }
return value
}, forget: { [self] in
accesses += 1
if denied { throw CredentialFailure() }
value = nil
})
}
}
@MainActor
private func waitForTranslation(_ condition: () -> Bool) async throws {
// This is a scheduling deadline, not a clock used by the behavior under test.
for _ in 0..<1_000 {
if condition() { return }
try await Task.sleep(for: .milliseconds(1))
}
try #require(condition(), "Translate did not reach the expected observable state")
}
@MainActor
private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
@Test @MainActor
func translationExecutionIsExplicitAndMyMemoryNeverTouchesCredentials() async throws {
_ = NSApplication.shared
let suite = "TranslateExecution.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let keys = TranslationKeys()
keys.value = "private-key"
let requests = TranslationRequests()
let tool = TranslationController(defaults: defaults, credentials: keys.store,
myMemory: { try await requests.translate($0) },
openAI: { try await requests.translate($0, key: $1) })
tool.input = { "Hello" }
let views = descendants(tool.view)
let output = try #require(views.compactMap { $0 as? NSTextView }.first)
tool.inputChanged()
#expect(requests.inputs.isEmpty)
tool.translate()
tool.translate() // A second submission while busy must not send another request.
try await waitForTranslation { requests.pending.count == 1 }
requests.pending[0].resume(returning: "こんにちは")
try await waitForTranslation { output.string == "こんにちは" }
#expect(requests.inputs.count == 1)
#expect(requests.inputs[0].text == "Hello")
#expect(requests.inputs[0].key == nil)
#expect(keys.accesses == 0)
}
@Test @MainActor
func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async throws {
_ = NSApplication.shared
let suite = "TranslateLifetime.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let keys = TranslationKeys()
keys.value = "saved-key"
let requests = TranslationRequests()
let tool = TranslationController(defaults: defaults, credentials: keys.store,
myMemory: { try await requests.translate($0) },
openAI: { try await requests.translate($0, key: $1) })
var input = "first"
tool.input = { input }
let views = descendants(tool.view)
let output = try #require(views.compactMap { $0 as? NSTextView }.first)
let provider = try #require(views.compactMap { $0 as? NSPopUpButton }.first)
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") })
tool.translate()
try await waitForTranslation { requests.pending.count == 1 }
input = "second"
tool.inputChanged()
tool.translate()
try await waitForTranslation { requests.pending.count == 2 }
requests.pending[0].resume(returning: "obsolete")
try await waitForTranslation { requests.returned == 1 }
#expect(output.string.isEmpty)
#expect(!button.isEnabled)
tool.translate()
#expect(requests.inputs.count == 2)
provider.selectItem(at: 1)
_ = tool.perform(provider.action, with: provider)
#expect(defaults.string(forKey: "translationProvider") == "openai")
tool.translate()
try await waitForTranslation { requests.pending.count == 3 }
#expect(requests.inputs[2].key == "saved-key")
requests.pending[1].resume(throwing: CredentialFailure())
try await waitForTranslation { requests.returned == 2 }
#expect(!button.isEnabled)
requests.pending[2].resume(returning: "最新")
try await waitForTranslation { output.string == "最新" }
#expect(button.isEnabled)
tool.translate()
try await waitForTranslation { requests.pending.count == 4 }
var closed = false
tool.closed = { closed = true }
tool.goBack()
requests.pending[3].resume(returning: "after close")
try await waitForTranslation { requests.returned == 4 }
#expect(closed)
#expect(output.string.isEmpty)
}
@Test @MainActor
func translationExecutionRecoversFromCredentialAndRequestFailures() async throws {
_ = NSApplication.shared
let suite = "TranslateCredentials.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.set("openai", forKey: "translationProvider")
defer { defaults.removePersistentDomain(forName: suite) }
let keys = TranslationKeys()
let requests = TranslationRequests()
let tool = TranslationController(defaults: defaults, credentials: keys.store,
myMemory: { try await requests.translate($0) },
openAI: { try await requests.translate($0, key: $1) })
tool.input = { "Hello" }
let views = descendants(tool.view)
let field = try #require(views.compactMap { $0 as? NSSecureTextField }.first)
let output = try #require(views.compactMap { $0 as? NSTextView }.first)
let status = try #require(views.compactMap { $0 as? NSTextField }.first { $0.accessibilityLabel() == "Translation status and errors" })
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") })
let forget = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("forgetKey") })
tool.translate() // No stored key.
#expect(requests.inputs.isEmpty)
#expect(button.isEnabled)
#expect(status.textColor == .systemRed)
field.stringValue = "typed-key"
keys.denied = true
tool.translate() // A failed save must not silently send the typed key.
#expect(requests.inputs.isEmpty)
#expect(field.stringValue == "typed-key")
#expect(keys.value == nil)
#expect(status.stringValue.contains("Credential access denied"))
field.stringValue = ""
tool.translate() // Failed reads are also recoverable without a request.
#expect(requests.inputs.isEmpty)
#expect(button.isEnabled)
keys.denied = false
field.stringValue = "typed-key"
tool.translate()
try await waitForTranslation { requests.pending.count == 1 }
#expect(keys.value == "typed-key")
#expect(field.stringValue.isEmpty)
#expect(requests.inputs[0].key == "typed-key")
requests.pending[0].resume(throwing: CredentialFailure())
try await waitForTranslation { button.isEnabled }
#expect(status.textColor == .systemRed)
tool.translate()
try await waitForTranslation { requests.pending.count == 2 }
forget.performClick(nil)
#expect(keys.value == nil)
#expect(button.isEnabled)
requests.pending[1].resume(returning: "forgotten request")
try await waitForTranslation { requests.returned == 2 }
#expect(output.string.isEmpty)
keys.value = "replacement"
tool.translate()
try await waitForTranslation { requests.pending.count == 3 }
requests.pending[2].resume(returning: "成功")
try await waitForTranslation { output.string == "成功" }
#expect(status.textColor != .systemRed)
}