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.
The launcher opens without a window animation; Settings is constructed only when needed, not before the launcher appears.
### Controls
| 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.
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.
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.
@@ -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.
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
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.
@@ -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.
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.
- **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: "Reset ranking", symbol: "arrow.counterclockwise", 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 availableEntries = Array(0..<6)
@@ -26,6 +27,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private var transitionGeneration = 0
private var panel: ActionsPanelView { view as! ActionsPanelView }
var open: (() -> Void)?
var copyEmoji: (() -> Void)?
var toggleFavorite: (() -> Void)?
var isFavorite = false
var showInFinder: (() -> Void)?
@@ -126,11 +128,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func show(for application: Application, relativeTo anchor: NSView) {
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) {
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) {
@@ -140,8 +142,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
_ = view
applicationLabel.stringValue = title
availableEntries = available
entries[0].button.title = emoji ? "Copy Emoji" : "Open"
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.doc" : "arrow.up.forward.app", accessibilityDescription: nil)
let openTitle = emoji ? "Copy and Paste" : "Open"
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")
let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
@@ -188,8 +192,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
}
func close() {
guard isShown else { return }
func close(animated: Bool = true) {
guard viewIfLoaded?.superview != nil, !isClosing || !animated else { return }
transitionGeneration += 1
let generation = transitionGeneration
isClosing = true
@@ -198,13 +202,23 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) }
mouseMonitor = nil
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
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.12
view.animator().alphaValue = 0
coveredPill?.animator().alphaValue = coveredPillAlpha
} completionHandler: { [weak self] in
} completionHandler: { [self] in
MainActor.assumeIsolated {
guard let self, self.transitionGeneration == generation, self.isClosing else { return }
guard self.transitionGeneration == generation, self.isClosing else { return }
self.view.removeFromSuperview()
self.coveredPill = nil
self.isClosing = false
@@ -215,9 +229,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visibleEntries = entries.indices.filter {
availableEntries.contains($0)
&& (query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil)
visibleEntries = availableEntries.filter {
query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
}
selectedEntry = visibleEntries.first
for index in entries.indices {
@@ -278,6 +291,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
case 3: resetRanking?()
case 4: quit?()
case 5: uninstall?()
case 6: copyEmoji?()
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)
}
}
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 startupRecoveryMessage: String?
@@ -117,7 +123,6 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
quitItem.target = NSApplication.shared
launcher.window?.contentView?.menu = menu
let loginLaunch = isLoginLaunch
settings.retryUninstallRecovery = { [uninstaller] in await uninstaller.recover() }
Task {
let report = await uninstaller.recover()
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 status = NSTextField(labelWithString: "")
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 actions = ApplicationActionsController()
private var favoriteCount = 0
@@ -19,6 +19,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
var input: (() -> String)?
var closed: (() -> Void)?
var copied: (() -> Void)?
var pasteRequested: (() -> Void)?
var focusSearch: (() -> Void)?
init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
@@ -59,7 +60,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
copyButton.target = self
copyButton.action = #selector(copySelection)
copyButton.action = #selector(copyAndPasteSelection)
copyButton.keyEquivalent = "\r"
copyButton.isBordered = false
copyButton.font = .systemFont(ofSize: 12, weight: .medium)
@@ -109,10 +110,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
@objc private func categoryChanged() { inputChanged() }
@objc func goBack() {
if actions.isShown { actions.close() } else { closed?() }
if actions.isShown { actions.close() } else { close() }
}
func close() {
actions.close()
actions.close(animated: false)
closed?()
}
override func cancelOperation(_ sender: Any?) { goBack() }
@@ -198,7 +199,8 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
guard results.indices.contains(selected) else { return }
let emoji = results[selected]
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.showEmoji(named: emoji.name, relativeTo: actionsButton)
copyButton.keyEquivalent = actions.isShown ? "" : "\r"
@@ -220,7 +222,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1)
case #selector(NSResponder.moveUp(_:)): moveVertically(-1)
case #selector(NSResponder.moveDown(_:)): moveVertically(1)
case #selector(NSResponder.insertNewline(_:)): copySelection()
case #selector(NSResponder.insertNewline(_:)): copyAndPasteSelection()
case #selector(NSResponder.cancelOperation(_:)): goBack()
default: return false
}
@@ -238,7 +240,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
(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 }
let value = results[selected].value
pasteboard.clearContents()
@@ -249,8 +254,8 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
var recent = defaults.stringArray(forKey: "recentEmoji") ?? []
recent.removeAll { $0 == value }
defaults.set(Array(([value] + recent).prefix(30)), forKey: "recentEmoji")
closed?()
copied?()
actions.close(animated: false)
if pasting { pasteRequested?() } else { copied?() }
}
func numberOfSections(in collectionView: NSCollectionView) -> Int { splitFavorites ? 2 : 1 }
@@ -273,7 +278,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
item.highlight(index == selected)
item.clicked = { [weak self] in
self?.selected = index
self?.copySelection()
self?.copyAndPasteSelection()
}
return item
}
+46 -71
View File
@@ -1,6 +1,7 @@
import AppKit
import ConjureKit
import CoreText
import ApplicationServices
@MainActor
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 openButton = NSButton(title: "Open Application", target: nil, action: nil)
private let history: LaunchHistory
private let defaults: UserDefaults
private let pasteboard: NSPasteboard
private var applications: [Application] = Application.commands
private var results: [Application] = []
private var calculation: CalculatorResult?
private var isCurrencyCalculation = false
private var currencyRates: CurrencyRates?
private var currencyFetchedAt: Date?
private var currencyTask: Task<Void, Never>?
private var currencyError: String?
private var currencyFailureQuery: String?
private let currency = CurrencyCalculation()
private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>?
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.uninstaller = uninstaller
self.defaults = defaults
self.pasteboard = pasteboard
let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless],
@@ -62,6 +64,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
defer: false
)
window.title = "Conjure"
window.animationBehavior = .none
window.appearance = NSAppearance(named: .darkAqua)
window.backgroundColor = .clear
window.isOpaque = false
@@ -104,6 +107,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
else { self.dismiss(restoringFocus: false) }
}
currency.changed = { [weak self] in self?.updateResults(preservingSelection: true) }
restorePosition()
}
@@ -111,10 +115,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
fatalError("Conjure creates its window programmatically")
}
deinit {
currencyTask?.cancel()
}
func present(command: Application.Command? = nil) {
guard !isLaunching, window?.attachedSheet == nil else { return }
if launcherFrameBeforeReview != nil {
@@ -123,7 +123,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return
}
closeCommand()
actions.close()
actions.close(animated: false)
(window as? LauncherPanel)?.beginNumberHintSession()
let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
@@ -150,8 +150,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard !isLaunching, window?.attachedSheet == nil else { return }
if window?.isVisible == true, window?.isKeyWindow == true,
launcherFrameBeforeReview == nil, activeCommand == command {
closeCommand()
actions.close()
dismiss()
} else {
present(command: command)
@@ -161,12 +159,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
if let review = uninstallReview { review.goBack(); return }
if isCommandOpen {
goBackCommand()
if restoringFocus { return }
}
closeCommand()
guard launcherFrameBeforeReview == nil else { return }
actions.close()
actions.close(animated: false)
alignmentGuides?.end()
let previous = previousApplication
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() {
var nameCounts: [String: Int] = [:]
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 }
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
let query = searchField.stringValue
if currencyFailureQuery != query {
currencyError = nil
currencyFailureQuery = nil
}
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)"
)
}
currency.update(query)
isCurrencyCalculation = currency.isCurrency
calculation = currency.result
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
if calculation != nil {
results = []
@@ -763,7 +712,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let calculation {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
if pasteboard.setString(value, forType: .string) {
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() {
translation?.goBack()
emojiPicker?.goBack()
@@ -837,11 +811,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
placeholder = "Enter English text to translate…"
accessibilityLabel = "English text to translate into Japanese"
case .emoji:
let tool = EmojiPickerController()
let tool = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
emojiPicker = tool
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) }
tool.copied = { [weak self] in self?.dismiss() }
tool.pasteRequested = { [weak self] in self?.pasteEmoji() }
controller = tool
placeholder = "Search emoji by name or :shortcode:…"
accessibilityLabel = "Search emoji"
@@ -871,7 +846,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
translation?.closed = close
emojiPicker?.closed = close
actions.close()
actions.close(animated: false)
(window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = ""
searchSymbol.isHidden = true
+8 -18
View File
@@ -249,6 +249,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refresh() {
refreshLogin()
recorder.isRecording = shortcuts.isRecording
commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0
recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut")
let name = shortcut.shortcut?.displayName
@@ -266,10 +267,9 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
@objc private func beginRecording() {
guard !recorder.isRecording else { return }
guard !shortcuts.isRecording else { return }
errorLabel.stringValue = ""
shortcuts.suspend()
recorder.isRecording = true
shortcuts.beginRecording()
refresh()
if window?.makeFirstResponder(recorder) != true {
cancelRecording()
@@ -283,9 +283,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
return
}
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."
restoreSavedShortcuts()
appendShortcutErrors(shortcuts.cancelRecording())
refresh()
return
}
@@ -293,28 +292,19 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
private func apply(_ candidate: KeyboardShortcut?) {
let wasRecording = recorder.isRecording
recorder.isRecording = false
errorLabel.stringValue = ""
do {
try shortcuts.setShortcut(candidate, at: destinationSelector.indexOfSelectedItem)
} catch {
errorLabel.stringValue = error.localizedDescription
}
if wasRecording { restoreSavedShortcuts() }
appendShortcutErrors(shortcuts.finishRecording(candidate, at: destinationSelector.indexOfSelectedItem))
refresh()
}
private func cancelRecording() {
guard recorder.isRecording else { return }
recorder.isRecording = false
guard shortcuts.isRecording else { return }
errorLabel.stringValue = ""
restoreSavedShortcuts()
appendShortcutErrors(shortcuts.cancelRecording())
refresh()
}
private func restoreSavedShortcuts() {
let errors = shortcuts.start()
private func appendShortcutErrors(_ errors: [String]) {
if !errors.isEmpty {
let message = errors.joined(separator: "\n")
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>?
var input: (() -> String)?
var closed: (() -> Void)?
private static let credential: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "Conjure.OpenAI",
kSecAttrAccount as String: "translation",
]
private let defaults: UserDefaults
private let credentialsStore: Credentials
private let myMemory: @MainActor (String) async throws -> String
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() {
view = NSView()
@@ -27,7 +41,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
title.font = .systemFont(ofSize: 13, weight: .semibold)
title.textColor = SunsetPalette.text
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.target = self
provider.action = #selector(providerChanged)
@@ -132,21 +146,18 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
func controlTextDidChange(_ notification: Notification) { inputChanged() }
@objc func goBack() {
task?.cancel()
task = nil
cancelRequest()
closed?()
}
func inputChanged() {
task?.cancel()
task = nil
translateButton.isEnabled = true
cancelRequest()
output.string = ""
setStatus("Press Return to translate.")
}
@objc private func providerChanged() {
UserDefaults.standard.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider")
defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider")
keyField.stringValue = ""
updateProvider()
}
@@ -165,33 +176,26 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
setStatus("Enter a valid API key before saving.", isError: true)
return
}
let data = Data(key.utf8)
var result = SecItemUpdate(Self.credential as CFDictionary, [kSecValueData as String: data] as CFDictionary)
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 {
do {
try credentialsStore.save(key)
keyField.stringValue = ""
setStatus("API key saved in Keychain. Press Return to translate.")
} else { showKeychainError(result) }
} catch { setStatus(error.localizedDescription, isError: true) }
}
@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 = nil
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) {
@@ -210,25 +214,27 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
var key = ""
if !myMemory {
if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } }
var query = Self.credential
query[kSecReturnData as String] = true
var item: CFTypeRef?
let result = SecItemCopyMatching(query as CFDictionary, &item)
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) }
else { showKeychainError(result) }
do {
guard let savedKey = try credentialsStore.load() else {
setStatus("Enter and save your OpenAI API key first.", isError: true)
return
}
key = savedKey
} catch {
setStatus(error.localizedDescription, isError: true)
return
}
key = savedKey
}
output.string = ""
setStatus("Translating…")
translateButton.isEnabled = false
let myMemoryTranslate = self.myMemory
let openAITranslate = self.openAI
task = Task { [weak self] in
do {
let translation = try await myMemory
? MyMemoryAPI.translate(text)
: TranslationAPI.translate(text, apiKey: key)
? myMemoryTranslate(text)
: openAITranslate(text, key)
guard !Task.isCancelled, let self else { return }
self.output.string = translation
self.setStatus("Japanese · Select text to copy (⌘C).")
@@ -241,4 +247,52 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText
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 action: @MainActor () -> Void
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(
defaults: UserDefaults = .standard,
@@ -44,7 +56,7 @@ public final class GlobalShortcut {
try activate(candidate)
defaults.set(candidate.storedValue, forKey: storageKey)
} else {
try native.unregister()
try unregister()
isRegistered = false
defaults.removeObject(forKey: storageKey)
}
@@ -53,7 +65,7 @@ public final class GlobalShortcut {
public func suspend() {
do {
try native.unregister()
try unregister()
isRegistered = false
} catch {
NSLog("Conjure could not suspend its shortcut: %@", error.localizedDescription)
@@ -61,7 +73,7 @@ public final class GlobalShortcut {
}
public func stop() {
native.dispose()
if let registration { registration.stop() } else { native.dispose() }
isRegistered = false
}
@@ -69,36 +81,22 @@ public final class GlobalShortcut {
action()
}
private func activate(_ candidate: KeyboardShortcut) throws {
try Self.rejectSystemConflict(candidate)
try native.register(candidate)
isRegistered = true
private func unregister() throws {
if let registration { try registration.unregister() } else { try native.unregister() }
}
private static func rejectSystemConflict(_ candidate: KeyboardShortcut) throws {
var copied: Unmanaged<CFArray>?
let status = CopySymbolicHotKeys(&copied)
let result = copied?.takeRetainedValue()
guard status == noErr, let result else {
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)
}
private func activate(_ candidate: KeyboardShortcut) throws {
if let registration {
try registration.activate(candidate)
} else {
try native.register(candidate)
}
isRegistered = true
}
}
enum ShortcutError: LocalizedError {
case invalidSavedShortcut
case systemConflict(String)
case inAppConflict(shortcut: String, action: String)
case duplicate(shortcut: String, destination: String)
case native(operation: String, status: OSStatus)
@@ -107,8 +105,6 @@ enum ShortcutError: LocalizedError {
switch self {
case .invalidSavedShortcut:
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):
return "\(shortcut) is already used by Conjure for \(action). Choose another shortcut."
case .duplicate(let shortcut, let destination):
+20 -1
View File
@@ -27,6 +27,13 @@ public final class GlobalShortcuts {
public let bindings: [GlobalShortcut]
private let defaults: UserDefaults
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(
defaults: UserDefaults = .standard,
@@ -60,11 +67,23 @@ public final class GlobalShortcuts {
return errors
}
public func suspend() {
public func beginRecording() {
isSuspended = true
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() {
isSuspended = false
bindings.forEach { $0.stop() }
+1 -5
View File
@@ -1,7 +1,3 @@
- [x] add an emoji picker inspired raycast's emoji picker
- [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.
- [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.
- [ ] add a notes app inspired by raycast notes
- [ ] 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 closed = false
var pasteRequests = 0
let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
picker.input = { query }
picker.closed = { closed = true }
picker.pasteRequested = {
#expect(pasteboard.string(forType: .string) == EmojiCatalog.search(query)[1].value)
pasteRequests += 1
}
_ = picker.view
let matches = EmojiCatalog.search(query)
#expect(picker.handle(#selector(NSResponder.moveRight(_:))))
#expect(picker.handle(#selector(NSResponder.insertNewline(_:))))
#expect(pasteboard.string(forType: .string) == matches[1].value)
#expect(closed)
#expect(!closed)
#expect(pasteRequests == 1)
#expect(defaults.stringArray(forKey: "recentEmoji") == [matches[1].value])
closed = false
query = "no-such-emoji"
picker.inputChanged()
picker.copySelection()
picker.copyAndPasteSelection()
#expect(!closed)
#expect(pasteboard.string(forType: .string) == matches[1].value)
#expect(pasteRequests == 1)
query = "👩‍💻"
picker.inputChanged()
picker.copySelection()
#expect(pasteboard.string(forType: .string) == "👩‍💻")
#expect(pasteRequests == 1)
let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
_ = reopened.view
@@ -43,13 +51,117 @@ import Testing
#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 {
_ = NSApplication.shared
let suite = "ConjureEmojiCommand.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
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)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
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))
#expect(grid.numberOfItems(inSection: 0) == 1)
#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(_:)))
#expect(!descendants(content).contains { $0 === grid })
#expect(search.stringValue == "emoji")
@@ -33,7 +33,7 @@ func duplicateGlobalBindingIsRejectedWhileSuspendedWithoutChangingSavedBinding()
defaults.set(candidate.storedValue, forKey: "globalActivationShortcut")
defaults.set(previous.storedValue, forKey: "globalCommandShortcut.emoji")
let shortcuts = GlobalShortcuts(defaults: defaults) { _ in }
shortcuts.suspend()
shortcuts.beginRecording()
do {
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)
}