From 9eb16ddd5a00180716891b5c8b31c6fc103d0441 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 17 Oct 2026 22:17:21 +0200 Subject: [PATCH] ui: move translation credentials into settings --- README.md | 6 +- .../Conjure/LauncherWindowController.swift | 10 ++- .../Conjure/SettingsWindowController.swift | 37 ++++++-- Sources/Conjure/TranslationController.swift | 89 ++++++------------ .../TranslationSettingsController.swift | 90 +++++++++++++++++++ .../EscapeDismissalTests.swift | 8 +- .../TranslationExecutionTests.swift | 32 +++++-- 7 files changed, 186 insertions(+), 86 deletions(-) create mode 100644 Sources/Conjure/TranslationSettingsController.swift diff --git a/README.md b/README.md index f376874..169913b 100644 --- a/README.md +++ b/README.md @@ -130,12 +130,12 @@ Notes autosave locally as RTFD packages, including embedded images, under `~/Lib ## 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. -- **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. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index e6d8bfe..ed16068 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -8,6 +8,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private let searchField = NSSearchField() private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let commandBack = NSButton(title: "", target: nil, action: nil) + private let translationHeader = NSStackView() private let tableView = ApplicationTableView() private let statusLabel = 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.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.alignment = .centerY header.spacing = 12 @@ -861,6 +863,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe let close: () -> Void = { [weak self, weak controller] in guard let self, let controller else { return } controller.view.removeFromSuperview() + self.translation?.providerPicker.removeFromSuperview() + self.translationHeader.isHidden = true self.translation = nil self.emojiPicker = nil self.notes = nil @@ -907,6 +911,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } controller.view.translatesAutoresizingMaskIntoConstraints = false content.addSubview(controller.view) + if let translation { + translationHeader.addArrangedSubview(translation.providerPicker) + translationHeader.isHidden = false + } NSLayoutConstraint.activate([ controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor), controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor), diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index 1fe5d42..802dbfc 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -5,6 +5,7 @@ import ServiceManagement @MainActor final class SettingsWindowController: NSWindowController, NSWindowDelegate { private let shortcuts: GlobalShortcuts + private let translationSettings = TranslationSettingsController() private let destinationSelector = NSPopUpButton(frame: .zero, pullsDown: false) private let commandSpaceButton = NSButton(title: "Use ⌘Space", target: nil, action: nil) private var shortcut: GlobalShortcut { shortcuts.bindings[destinationSelector.indexOfSelectedItem] } @@ -146,15 +147,20 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { let loginHeading = NSTextField(labelWithString: "Startup") loginHeading.font = .systemFont(ofSize: 20, weight: .semibold) 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: [ 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.alignment = .leading stack.spacing = 12 stack.setCustomSpacing(32, after: keyboardSettings) + stack.setCustomSpacing(32, after: recoveryButton) stack.translatesAutoresizingMaskIntoConstraints = false let content = NSVisualEffectView() content.material = .hudWindow @@ -165,12 +171,27 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { background.autoresizingMask = [.width, .height] background.setAccessibilityElement(false) 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([ - stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24), - stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), - stack.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor, constant: 24), - stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24), + scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor), + scroll.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor), + 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) ]) for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] { @@ -389,3 +410,7 @@ private final class ShortcutRecorderButton: NSButton { return resigned } } + +private final class SettingsDocumentView: NSView { + override var isFlipped: Bool { true } +} diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index 8fcf603..d09bf58 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -3,13 +3,11 @@ import ConjureKit import Security @MainActor -final class TranslationController: NSViewController, NSTextFieldDelegate { - private let keyField = NSSecureTextField() - private let provider = NSPopUpButton() - private let credentials = NSStackView() - private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 } +final class TranslationController: NSViewController { + let providerPicker = NSPopUpButton() + private var usesMyMemory: Bool { providerPicker.indexOfSelectedItem == 0 } 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 translateButton = NSButton(title: "Translate", target: nil, action: nil) private var task: Task? @@ -40,19 +38,16 @@ final class TranslationController: NSViewController, NSTextFieldDelegate { let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese") title.font = .systemFont(ofSize: 13, weight: .semibold) title.textColor = SunsetPalette.text - provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) - provider.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0) - provider.setAccessibilityLabel("Translation provider") - provider.target = self - provider.action = #selector(providerChanged) - keyField.placeholderString = "OpenAI API key (saved in Keychain)" - keyField.setAccessibilityLabel("OpenAI API key") - keyField.delegate = self - keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true - let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey)) - let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey)) - for control in [keyField, save, forget] { credentials.addArrangedSubview(control) } - credentials.distribution = .fill + providerPicker.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) + providerPicker.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0) + providerPicker.setAccessibilityLabel("Translation provider") + providerPicker.target = self + providerPicker.action = #selector(providerChanged) + settingsButton.target = self + settingsButton.action = #selector(openSettings) + settingsButton.isHidden = true + NotificationCenter.default.addObserver(self, selector: #selector(credentialsChanged), + name: Notification.Name("ConjureTranslationCredentialsChanged"), object: nil) translateButton.target = self translateButton.action = #selector(translate) translateButton.keyEquivalent = "\r" @@ -79,7 +74,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate { returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), 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.alignment = .centerY footer.spacing = 12 @@ -98,17 +93,13 @@ final class TranslationController: NSViewController, NSTextFieldDelegate { output.isVerticallyResizable = true output.autoresizingMask = [.width] 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.setAccessibilityLabel("Translation status and errors") status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let stack = NSStackView(views: [title, provider, disclosure, credentials, scroll]) + let stack = NSStackView(views: [title, scroll]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 12 - stack.setCustomSpacing(4, after: provider) stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ @@ -120,15 +111,17 @@ final class TranslationController: NSViewController, NSTextFieldDelegate { footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), footer.heightAnchor.constraint(equalToConstant: 36), - credentials.widthAnchor.constraint(equalTo: stack.widthAnchor), - disclosure.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.widthAnchor.constraint(equalTo: stack.widthAnchor), 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() { cancelRequest() @@ -138,45 +131,15 @@ final class TranslationController: NSViewController, NSTextFieldDelegate { func inputChanged() { cancelRequest() output.string = "" + settingsButton.isHidden = true setStatus("Press Return to translate.") } @objc private func providerChanged() { 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() } - @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() { task?.cancel() task = nil @@ -198,14 +161,15 @@ final class TranslationController: NSViewController, NSTextFieldDelegate { let myMemory = usesMyMemory var key = "" if !myMemory { - if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } } do { 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 } key = savedKey } catch { + settingsButton.isHidden = false setStatus(error.localizedDescription, isError: true) return } @@ -225,6 +189,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate { self.setStatus("Japanese · Select text to copy (⌘C).") } catch { guard !Task.isCancelled, let self else { return } + self.settingsButton.isHidden = myMemory self.setStatus("Translation failed: \(error.localizedDescription)", isError: true) } guard let self else { return } diff --git a/Sources/Conjure/TranslationSettingsController.swift b/Sources/Conjure/TranslationSettingsController.swift new file mode 100644 index 0000000..18f9f73 --- /dev/null +++ b/Sources/Conjure/TranslationSettingsController.swift @@ -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 + } +} diff --git a/Tests/ConjureKitTests/EscapeDismissalTests.swift b/Tests/ConjureKitTests/EscapeDismissalTests.swift index 0d7566d..5dfcfd0 100644 --- a/Tests/ConjureKitTests/EscapeDismissalTests.swift +++ b/Tests/ConjureKitTests/EscapeDismissalTests.swift @@ -3,7 +3,7 @@ import ConjureKit import Testing @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 { _ = NSApplication.shared let suite = "ConjureEscape.\(UUID().uuidString)" @@ -47,12 +47,6 @@ import Testing $0.accessibilityLabel() == "Japanese translation" }) 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" { let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first) window.makeFirstResponder(editor) diff --git a/Tests/ConjureKitTests/TranslationExecutionTests.swift b/Tests/ConjureKitTests/TranslationExecutionTests.swift index b417668..c91a792 100644 --- a/Tests/ConjureKitTests/TranslationExecutionTests.swift +++ b/Tests/ConjureKitTests/TranslationExecutionTests.swift @@ -56,6 +56,8 @@ private func waitForTranslation(_ condition: () -> Bool) async throws { @MainActor private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } +@Suite(.serialized) +struct TranslationExecutionTests { @Test @MainActor func translationExecutionIsExplicitAndMyMemoryNeverTouchesCredentials() async throws { _ = NSApplication.shared @@ -100,7 +102,7 @@ func translationExecutionRejectsOldCompletionsWithoutClearingNewRequests() async 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 provider = tool.providerPicker let button = try #require(views.compactMap { $0 as? NSButton }.first { $0.action == NSSelectorFromString("translate") }) tool.translate() try await waitForTranslation { requests.pending.count == 1 } @@ -153,29 +155,39 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws 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 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 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") }) + 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. #expect(requests.inputs.isEmpty) #expect(button.isEnabled) #expect(status.textColor == .systemRed) + #expect(!openSettings.isHidden) 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 - 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(field.stringValue == "typed-key") #expect(keys.value == nil) - #expect(status.stringValue.contains("Credential access denied")) - field.stringValue = "" + #expect(settingsViews.compactMap { $0 as? NSTextField }.contains { $0.stringValue.contains("Credential access denied") }) tool.translate() // Failed reads are also recoverable without a request. #expect(requests.inputs.isEmpty) #expect(button.isEnabled) keys.denied = false - field.stringValue = "typed-key" + save.performClick(nil) + #expect(keys.value == "typed-key") + #expect(field.stringValue.isEmpty) tool.translate() try await waitForTranslation { requests.pending.count == 1 } #expect(keys.value == "typed-key") @@ -184,6 +196,11 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws requests.pending[0].resume(throwing: CredentialFailure()) try await waitForTranslation { button.isEnabled } #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() try await waitForTranslation { requests.pending.count == 2 } forget.performClick(nil) @@ -199,3 +216,4 @@ func translationExecutionRecoversFromCredentialAndRequestFailures() async throws try await waitForTranslation { output.string == "成功" } #expect(status.textColor != .systemRed) } +}