ui: move translation credentials into settings

This commit is contained in:
2026-10-17 22:17:21 +02:00
parent b847a483f0
commit 9eb16ddd5a
7 changed files with 186 additions and 86 deletions
+3 -3
View File
@@ -130,12 +130,12 @@ Notes autosave locally as RTFD packages, including embedded images, under `~/Lib
## Translation ## Translation
Search for **Conjure Translate**, identified by its centered 🌏 icon, 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 returns to the launcher and restores your search; Escape dismisses Conjure. Search for **Conjure Translate**, identified by its centered 🌏 icon, and open it. Enter English in the search bar, choose a provider in the launcher header, 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 returns to the launcher and restores your search; Escape dismisses Conjure.
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. Editing the input, switching providers, saving or forgetting the key in Settings, or leaving Translate cancels the active request; cancelled results are not shown. After a request fails, submit again to retry.
- **MyMemory:** No account or key required. Limits are 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php) for anonymous use. - **MyMemory:** No account or key required. Limits are 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php) for anonymous use.
- **OpenAI:** Requires an API key, access to `gpt-6-luna`, and sufficient API billing. Choose **Save Key** to store it in macOS Keychain; **Forget Key** removes it. Requests disable API response storage, but OpenAI's retention policies still apply. - **OpenAI:** Requires an API key, access to `gpt-6-luna`, and sufficient API billing. Manage the key in **Settings > Conjure Translate**: **Save Key** stores it in macOS Keychain; **Forget Key** removes it. Translate never saves a key implicitly. Requests disable API response storage, but OpenAI's retention policies still apply.
**Translation sends your input to the selected third-party provider. Avoid sensitive content.** There is no automatic fallback between providers. MyMemory never receives your OpenAI key. **Translation sends your input to the selected third-party provider. Avoid sensitive content.** There is no automatic fallback between providers. MyMemory never receives your OpenAI key.
@@ -8,6 +8,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private let searchField = NSSearchField() private let searchField = NSSearchField()
private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
private let commandBack = NSButton(title: "", target: nil, action: nil) private let commandBack = NSButton(title: "", target: nil, action: nil)
private let translationHeader = NSStackView()
private let tableView = ApplicationTableView() private let tableView = ApplicationTableView()
private let statusLabel = NSTextField(labelWithString: "Finding applications…") private let statusLabel = NSTextField(labelWithString: "Finding applications…")
private let emptyLabel = NSTextField(labelWithString: "Finding applications…") private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
@@ -276,7 +277,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
commandBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), commandBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
]) ])
let header = NSStackView(views: [searchSymbol, commandBack, searchField]) translationHeader.isHidden = true
let header = NSStackView(views: [searchSymbol, commandBack, searchField, translationHeader])
header.orientation = .horizontal header.orientation = .horizontal
header.alignment = .centerY header.alignment = .centerY
header.spacing = 12 header.spacing = 12
@@ -861,6 +863,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let close: () -> Void = { [weak self, weak controller] in let close: () -> Void = { [weak self, weak controller] in
guard let self, let controller else { return } guard let self, let controller else { return }
controller.view.removeFromSuperview() controller.view.removeFromSuperview()
self.translation?.providerPicker.removeFromSuperview()
self.translationHeader.isHidden = true
self.translation = nil self.translation = nil
self.emojiPicker = nil self.emojiPicker = nil
self.notes = nil self.notes = nil
@@ -907,6 +911,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
controller.view.translatesAutoresizingMaskIntoConstraints = false controller.view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(controller.view) content.addSubview(controller.view)
if let translation {
translationHeader.addArrangedSubview(translation.providerPicker)
translationHeader.isHidden = false
}
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor), controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor), controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
+31 -6
View File
@@ -5,6 +5,7 @@ import ServiceManagement
@MainActor @MainActor
final class SettingsWindowController: NSWindowController, NSWindowDelegate { final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let shortcuts: GlobalShortcuts private let shortcuts: GlobalShortcuts
private let translationSettings = TranslationSettingsController()
private let destinationSelector = NSPopUpButton(frame: .zero, pullsDown: false) private let destinationSelector = NSPopUpButton(frame: .zero, pullsDown: false)
private let commandSpaceButton = NSButton(title: "Use ⌘Space", target: nil, action: nil) private let commandSpaceButton = NSButton(title: "Use ⌘Space", target: nil, action: nil)
private var shortcut: GlobalShortcut { shortcuts.bindings[destinationSelector.indexOfSelectedItem] } private var shortcut: GlobalShortcut { shortcuts.bindings[destinationSelector.indexOfSelectedItem] }
@@ -146,15 +147,20 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
let loginHeading = NSTextField(labelWithString: "Startup") let loginHeading = NSTextField(labelWithString: "Startup")
loginHeading.font = .systemFont(ofSize: 20, weight: .semibold) loginHeading.font = .systemFont(ofSize: 20, weight: .semibold)
loginHeading.textColor = SunsetPalette.text loginHeading.textColor = SunsetPalette.text
let translationHeading = NSTextField(labelWithString: "Conjure Translate")
translationHeading.font = .systemFont(ofSize: 20, weight: .semibold)
translationHeading.textColor = SunsetPalette.text
let stack = NSStackView(views: [ let stack = NSStackView(views: [
heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings, heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton,
translationHeading, translationSettings.view
]) ])
stack.orientation = .vertical stack.orientation = .vertical
stack.alignment = .leading stack.alignment = .leading
stack.spacing = 12 stack.spacing = 12
stack.setCustomSpacing(32, after: keyboardSettings) stack.setCustomSpacing(32, after: keyboardSettings)
stack.setCustomSpacing(32, after: recoveryButton)
stack.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false
let content = NSVisualEffectView() let content = NSVisualEffectView()
content.material = .hudWindow content.material = .hudWindow
@@ -165,12 +171,27 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
background.autoresizingMask = [.width, .height] background.autoresizingMask = [.width, .height]
background.setAccessibilityElement(false) background.setAccessibilityElement(false)
content.addSubview(background) content.addSubview(background)
content.addSubview(stack) let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
scroll.translatesAutoresizingMaskIntoConstraints = false
let document = SettingsDocumentView()
document.translatesAutoresizingMaskIntoConstraints = false
document.addSubview(stack)
scroll.documentView = document
content.addSubview(scroll)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24), scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor),
stack.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor, constant: 24), scroll.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor),
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24), scroll.bottomAnchor.constraint(equalTo: content.bottomAnchor),
document.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
stack.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -24),
stack.topAnchor.constraint(equalTo: document.topAnchor, constant: 24),
stack.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -24),
translationSettings.view.widthAnchor.constraint(equalTo: stack.widthAnchor),
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190) recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
]) ])
for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] { for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] {
@@ -389,3 +410,7 @@ private final class ShortcutRecorderButton: NSButton {
return resigned return resigned
} }
} }
private final class SettingsDocumentView: NSView {
override var isFlipped: Bool { true }
}
+27 -62
View File
@@ -3,13 +3,11 @@ import ConjureKit
import Security import Security
@MainActor @MainActor
final class TranslationController: NSViewController, NSTextFieldDelegate { final class TranslationController: NSViewController {
private let keyField = NSSecureTextField() let providerPicker = NSPopUpButton()
private let provider = NSPopUpButton() private var usesMyMemory: Bool { providerPicker.indexOfSelectedItem == 0 }
private let credentials = NSStackView()
private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 }
private let output = NSTextView() private let output = NSTextView()
private let disclosure = NSTextField(wrappingLabelWithString: "") private let settingsButton = NSButton(title: "Open Translate Settings", target: nil, action: nil)
private let status = NSTextField(wrappingLabelWithString: "") private let status = NSTextField(wrappingLabelWithString: "")
private let translateButton = NSButton(title: "Translate", target: nil, action: nil) private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
@@ -40,19 +38,16 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese") let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
title.font = .systemFont(ofSize: 13, weight: .semibold) title.font = .systemFont(ofSize: 13, weight: .semibold)
title.textColor = SunsetPalette.text title.textColor = SunsetPalette.text
provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) providerPicker.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
provider.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0) providerPicker.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0)
provider.setAccessibilityLabel("Translation provider") providerPicker.setAccessibilityLabel("Translation provider")
provider.target = self providerPicker.target = self
provider.action = #selector(providerChanged) providerPicker.action = #selector(providerChanged)
keyField.placeholderString = "OpenAI API key (saved in Keychain)" settingsButton.target = self
keyField.setAccessibilityLabel("OpenAI API key") settingsButton.action = #selector(openSettings)
keyField.delegate = self settingsButton.isHidden = true
keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true NotificationCenter.default.addObserver(self, selector: #selector(credentialsChanged),
let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey)) name: Notification.Name("ConjureTranslationCredentialsChanged"), object: nil)
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
for control in [keyField, save, forget] { credentials.addArrangedSubview(control) }
credentials.distribution = .fill
translateButton.target = self translateButton.target = self
translateButton.action = #selector(translate) translateButton.action = #selector(translate)
translateButton.keyEquivalent = "\r" translateButton.keyEquivalent = "\r"
@@ -79,7 +74,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12), returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
]) ])
let footer = NSStackView(views: [status, NSView(), actionPill]) let footer = NSStackView(views: [status, settingsButton, NSView(), actionPill])
footer.orientation = .horizontal footer.orientation = .horizontal
footer.alignment = .centerY footer.alignment = .centerY
footer.spacing = 12 footer.spacing = 12
@@ -98,17 +93,13 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
output.isVerticallyResizable = true output.isVerticallyResizable = true
output.autoresizingMask = [.width] output.autoresizingMask = [.width]
output.textContainer?.widthTracksTextView = true output.textContainer?.widthTracksTextView = true
disclosure.textColor = SunsetPalette.secondaryText
disclosure.font = .systemFont(ofSize: 12)
disclosure.setAccessibilityLabel("Translation provider, privacy, and limits")
status.font = .systemFont(ofSize: 12) status.font = .systemFont(ofSize: 12)
status.setAccessibilityLabel("Translation status and errors") status.setAccessibilityLabel("Translation status and errors")
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let stack = NSStackView(views: [title, provider, disclosure, credentials, scroll]) let stack = NSStackView(views: [title, scroll])
stack.orientation = .vertical stack.orientation = .vertical
stack.alignment = .leading stack.alignment = .leading
stack.spacing = 12 stack.spacing = 12
stack.setCustomSpacing(4, after: provider)
stack.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack) view.addSubview(stack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@@ -120,15 +111,17 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
footer.heightAnchor.constraint(equalToConstant: 36), footer.heightAnchor.constraint(equalToConstant: 36),
credentials.widthAnchor.constraint(equalTo: stack.widthAnchor),
disclosure.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
]) ])
updateProvider() inputChanged()
} }
func controlTextDidChange(_ notification: Notification) { inputChanged() } @objc private func openSettings() {
NSApp.sendAction(NSSelectorFromString("showSettings:"), to: NSApp.delegate, from: self)
}
@objc private func credentialsChanged() { inputChanged() }
@objc func goBack() { @objc func goBack() {
cancelRequest() cancelRequest()
@@ -138,45 +131,15 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
func inputChanged() { func inputChanged() {
cancelRequest() cancelRequest()
output.string = "" output.string = ""
settingsButton.isHidden = true
setStatus("Press Return to translate.") setStatus("Press Return to translate.")
} }
@objc private func providerChanged() { @objc private func providerChanged() {
defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider") defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider")
keyField.stringValue = ""
updateProvider()
}
private func updateProvider() {
credentials.isHidden = usesMyMemory
disclosure.stringValue = usesMyMemory
? "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory; avoid sensitive content."
: "OpenAI · gpt-6-luna · Text is sent to OpenAI only when you translate."
inputChanged() inputChanged()
} }
@objc private func saveKey() {
let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else {
setStatus("Enter a valid API key before saving.", isError: true)
return
}
do {
try credentialsStore.save(key)
keyField.stringValue = ""
setStatus("API key saved in Keychain. Press Return to translate.")
} 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() { private func cancelRequest() {
task?.cancel() task?.cancel()
task = nil task = nil
@@ -198,14 +161,15 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
let myMemory = usesMyMemory let myMemory = usesMyMemory
var key = "" var key = ""
if !myMemory { if !myMemory {
if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } }
do { do {
guard let savedKey = try credentialsStore.load() else { guard let savedKey = try credentialsStore.load() else {
setStatus("Enter and save your OpenAI API key first.", isError: true) settingsButton.isHidden = false
setStatus("Save your OpenAI API key in Settings first.", isError: true)
return return
} }
key = savedKey key = savedKey
} catch { } catch {
settingsButton.isHidden = false
setStatus(error.localizedDescription, isError: true) setStatus(error.localizedDescription, isError: true)
return return
} }
@@ -225,6 +189,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate {
self.setStatus("Japanese · Select text to copy (⌘C).") self.setStatus("Japanese · Select text to copy (⌘C).")
} catch { } catch {
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.settingsButton.isHidden = myMemory
self.setStatus("Translation failed: \(error.localizedDescription)", isError: true) self.setStatus("Translation failed: \(error.localizedDescription)", isError: true)
} }
guard let self else { return } guard let self else { return }
@@ -0,0 +1,90 @@
import AppKit
@MainActor
final class TranslationSettingsController: NSViewController {
private let keyField = NSSecureTextField()
private let status = NSTextField(wrappingLabelWithString: "")
private let credentials: TranslationController.Credentials
init(credentials: TranslationController.Credentials = .keychain) {
self.credentials = credentials
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("Conjure creates Translate settings programmatically") }
override func loadView() {
view = NSView()
keyField.placeholderString = "OpenAI API key (saved in Keychain)"
keyField.setAccessibilityLabel("OpenAI API key")
let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey))
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
for button in [save, forget] {
button.bezelStyle = .rounded
button.contentTintColor = SunsetPalette.accent
}
let controls = NSStackView(views: [keyField, save, forget])
controls.orientation = .horizontal
controls.alignment = .centerY
controls.spacing = 8
keyField.setContentHuggingPriority(.defaultLow, for: .horizontal)
keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
status.setAccessibilityLabel("Translation credential status and errors")
let myMemory = NSTextField(wrappingLabelWithString:
"MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory only when you translate; avoid sensitive content."
)
let openAI = NSTextField(wrappingLabelWithString:
"OpenAI · gpt-6-luna · Text is sent to OpenAI only when you explicitly translate. Saving a key does not send your text. Your API key is stored in Keychain."
)
for label in [status, myMemory, openAI] {
label.textColor = SunsetPalette.secondaryText
}
let stack = NSStackView(views: [controls, status, myMemory, openAI])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
controls.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
for label in [status, myMemory, openAI] {
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
do {
setStatus(try credentials.load() == nil ? "No OpenAI API key saved." : "OpenAI API key saved in Keychain.")
} catch { setStatus(error.localizedDescription, isError: true) }
}
@objc private func saveKey() {
let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else {
setStatus("Enter a valid API key before saving.", isError: true)
return
}
do {
try credentials.save(key)
keyField.stringValue = ""
setStatus("API key saved in Keychain.")
NotificationCenter.default.post(name: Notification.Name("ConjureTranslationCredentialsChanged"), object: self)
} catch { setStatus(error.localizedDescription, isError: true) }
}
@objc private func forgetKey() {
do {
try credentials.forget()
keyField.stringValue = ""
setStatus("API key removed.")
NotificationCenter.default.post(name: Notification.Name("ConjureTranslationCredentialsChanged"), object: self)
} catch { setStatus(error.localizedDescription, isError: true) }
}
private func setStatus(_ message: String, isError: Bool = false) {
status.stringValue = message
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
}
}
@@ -3,7 +3,7 @@ import ConjureKit
import Testing import Testing
@testable import Conjure @testable import Conjure
@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-key", "notes-editor"]) @Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "notes-editor"])
@MainActor func escapeDismissesEntireLauncher(_ destination: String) throws { @MainActor func escapeDismissesEntireLauncher(_ destination: String) throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "ConjureEscape.\(UUID().uuidString)" let suite = "ConjureEscape.\(UUID().uuidString)"
@@ -47,12 +47,6 @@ import Testing
$0.accessibilityLabel() == "Japanese translation" $0.accessibilityLabel() == "Japanese translation"
}) })
window.makeFirstResponder(output) window.makeFirstResponder(output)
} else if destination == "translate-key" {
let provider = try #require(descendants(content).compactMap { $0 as? NSPopUpButton }.first)
provider.selectItem(at: 1)
_ = provider.target?.perform(provider.action, with: provider)
let key = try #require(descendants(content).compactMap { $0 as? NSSecureTextField }.first)
window.makeFirstResponder(key)
} else if destination == "notes-editor" { } else if destination == "notes-editor" {
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first) let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor) window.makeFirstResponder(editor)
@@ -56,6 +56,8 @@ private func waitForTranslation(_ condition: () -> Bool) async throws {
@MainActor @MainActor
private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
@Suite(.serialized)
struct TranslationExecutionTests {
@Test @MainActor @Test @MainActor
func translationExecutionIsExplicitAndMyMemoryNeverTouchesCredentials() async throws { func translationExecutionIsExplicitAndMyMemoryNeverTouchesCredentials() async throws {
_ = NSApplication.shared _ = NSApplication.shared
@@ -100,7 +102,7 @@ func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async
tool.input = { input } tool.input = { input }
let views = descendants(tool.view) let views = descendants(tool.view)
let output = try #require(views.compactMap { $0 as? NSTextView }.first) let output = try #require(views.compactMap { $0 as? NSTextView }.first)
let provider = try #require(views.compactMap { $0 as? NSPopUpButton }.first) let provider = tool.providerPicker
let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") }) let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") })
tool.translate() tool.translate()
try await waitForTranslation { requests.pending.count == 1 } try await waitForTranslation { requests.pending.count == 1 }
@@ -153,29 +155,39 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
openAI: { try await requests.translate($0, key: $1) }) openAI: { try await requests.translate($0, key: $1) })
tool.input = { "Hello" } tool.input = { "Hello" }
let views = descendants(tool.view) let views = descendants(tool.view)
let field = try #require(views.compactMap { $0 as? NSSecureTextField }.first) let settings = TranslationSettingsController(credentials: keys.store)
let settingsViews = descendants(settings.view)
let field = try #require(settingsViews.compactMap { $0 as? NSSecureTextField }.first)
let output = try #require(views.compactMap { $0 as? NSTextView }.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 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 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") }) let save = try #require(settingsViews.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("saveKey") })
let forget = try #require(settingsViews.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("forgetKey") })
let openSettings = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("openSettings") })
tool.translate() // No stored key. tool.translate() // No stored key.
#expect(requests.inputs.isEmpty) #expect(requests.inputs.isEmpty)
#expect(button.isEnabled) #expect(button.isEnabled)
#expect(status.textColor == .systemRed) #expect(status.textColor == .systemRed)
#expect(!openSettings.isHidden)
field.stringValue = "typed-key" field.stringValue = "typed-key"
tool.translate() // Typing in Settings must not implicitly save on Translate.
#expect(requests.inputs.isEmpty)
#expect(keys.value == nil)
#expect(field.stringValue == "typed-key")
keys.denied = true keys.denied = true
tool.translate() // A failed save must not silently send the typed key. save.performClick(nil) // A failed save must retain the typed key for retry.
#expect(requests.inputs.isEmpty) #expect(requests.inputs.isEmpty)
#expect(field.stringValue == "typed-key") #expect(field.stringValue == "typed-key")
#expect(keys.value == nil) #expect(keys.value == nil)
#expect(status.stringValue.contains("Credential access denied")) #expect(settingsViews.compactMap { $0 as? NSTextField }.contains { $0.stringValue.contains("Credential access denied") })
field.stringValue = ""
tool.translate() // Failed reads are also recoverable without a request. tool.translate() // Failed reads are also recoverable without a request.
#expect(requests.inputs.isEmpty) #expect(requests.inputs.isEmpty)
#expect(button.isEnabled) #expect(button.isEnabled)
keys.denied = false keys.denied = false
field.stringValue = "typed-key" save.performClick(nil)
#expect(keys.value == "typed-key")
#expect(field.stringValue.isEmpty)
tool.translate() tool.translate()
try await waitForTranslation { requests.pending.count == 1 } try await waitForTranslation { requests.pending.count == 1 }
#expect(keys.value == "typed-key") #expect(keys.value == "typed-key")
@@ -184,6 +196,11 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
requests.pending[0].resume(throwing: CredentialFailure()) requests.pending[0].resume(throwing: CredentialFailure())
try await waitForTranslation { button.isEnabled } try await waitForTranslation { button.isEnabled }
#expect(status.textColor == .systemRed) #expect(status.textColor == .systemRed)
keys.denied = true
forget.performClick(nil)
#expect(keys.value == "typed-key")
#expect(settingsViews.compactMap { $0 as? NSTextField }.contains { $0.stringValue.contains("Credential access denied") })
keys.denied = false
tool.translate() tool.translate()
try await waitForTranslation { requests.pending.count == 2 } try await waitForTranslation { requests.pending.count == 2 }
forget.performClick(nil) forget.performClick(nil)
@@ -199,3 +216,4 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws
try await waitForTranslation { output.string == "成功" } try await waitForTranslation { output.string == "成功" }
#expect(status.textColor != .systemRed) #expect(status.textColor != .systemRed)
} }
}