Compare commits
8 Commits
27c203b336
...
cd2e3da330
| Author | SHA1 | Date | |
|---|---|---|---|
| cd2e3da330 | |||
| 586c8a7560 | |||
| f8588adebe | |||
| 9979639804 | |||
| a6ea75821b | |||
| c8b2e8e0c5 | |||
| 6725be8ad5 | |||
| 04e505b846 |
+12
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user