diff --git a/README.md b/README.md index d83389b..f8064e4 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,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. diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index 9cfddab..dfeb534 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -15,11 +15,25 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText private var task: Task? var input: (() -> String)? var closed: (() -> Void)? - private static let credential: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "Conjure.OpenAI", - kSecAttrAccount as String: "translation", - ] + private let defaults: UserDefaults + private let credentialsStore: Credentials + private let myMemory: @MainActor (String) async throws -> String + private let openAI: @MainActor (String, String) async throws -> String + + init( + defaults: UserDefaults = .standard, + credentials: Credentials = .keychain, + myMemory: @escaping @MainActor (String) async throws -> String = { try await MyMemoryAPI.translate($0) }, + openAI: @escaping @MainActor (String, String) async throws -> String = { try await TranslationAPI.translate($0, apiKey: $1) } + ) { + self.defaults = defaults + self.credentialsStore = credentials + self.myMemory = myMemory + self.openAI = openAI + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("Conjure creates Translate programmatically") } override func loadView() { view = NSView() @@ -27,7 +41,7 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText title.font = .systemFont(ofSize: 13, weight: .semibold) title.textColor = SunsetPalette.text provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) - provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0) + provider.selectItem(at: defaults.string(forKey: "translationProvider") == "openai" ? 1 : 0) provider.setAccessibilityLabel("Translation provider") provider.target = self provider.action = #selector(providerChanged) @@ -132,21 +146,18 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText func controlTextDidChange(_ notification: Notification) { inputChanged() } @objc func goBack() { - task?.cancel() - task = nil + cancelRequest() closed?() } func inputChanged() { - task?.cancel() - task = nil - translateButton.isEnabled = true + cancelRequest() output.string = "" setStatus("Press Return to translate.") } @objc private func providerChanged() { - UserDefaults.standard.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider") + defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider") keyField.stringValue = "" updateProvider() } @@ -165,33 +176,26 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText setStatus("Enter a valid API key before saving.", isError: true) return } - let data = Data(key.utf8) - var result = SecItemUpdate(Self.credential as CFDictionary, [kSecValueData as String: data] as CFDictionary) - if result == errSecItemNotFound { - var item = Self.credential - item[kSecValueData as String] = data - item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly - result = SecItemAdd(item as CFDictionary, nil) - } - if result == errSecSuccess { + do { + try credentialsStore.save(key) keyField.stringValue = "" setStatus("API key saved in Keychain. Press Return to translate.") - } else { showKeychainError(result) } + } catch { setStatus(error.localizedDescription, isError: true) } } @objc private func forgetKey() { + cancelRequest() + do { + try credentialsStore.forget() + keyField.stringValue = "" + setStatus("API key removed.") + } catch { setStatus(error.localizedDescription, isError: true) } + } + + private func cancelRequest() { task?.cancel() task = nil translateButton.isEnabled = true - let result = SecItemDelete(Self.credential as CFDictionary) - if result == errSecSuccess || result == errSecItemNotFound { - keyField.stringValue = "" - setStatus("API key removed.") - } else { showKeychainError(result) } - } - - private func showKeychainError(_ code: OSStatus) { - setStatus("Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))", isError: true) } private func setStatus(_ message: String, isError: Bool = false) { @@ -210,25 +214,27 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText var key = "" if !myMemory { if !keyField.stringValue.isEmpty { saveKey(); guard keyField.stringValue.isEmpty else { return } } - var query = Self.credential - query[kSecReturnData as String] = true - var item: CFTypeRef? - let result = SecItemCopyMatching(query as CFDictionary, &item) - guard result == errSecSuccess, let data = item as? Data, let savedKey = String(data: data, encoding: .utf8) else { - if result == errSecItemNotFound { setStatus("Enter and save your OpenAI API key first.", isError: true) } - else { showKeychainError(result) } + do { + guard let savedKey = try credentialsStore.load() else { + setStatus("Enter and save your OpenAI API key first.", isError: true) + return + } + key = savedKey + } catch { + setStatus(error.localizedDescription, isError: true) return } - key = savedKey } output.string = "" setStatus("Translating…") translateButton.isEnabled = false + let myMemoryTranslate = self.myMemory + let openAITranslate = self.openAI task = Task { [weak self] in do { let translation = try await myMemory - ? MyMemoryAPI.translate(text) - : TranslationAPI.translate(text, apiKey: key) + ? myMemoryTranslate(text) + : openAITranslate(text, key) guard !Task.isCancelled, let self else { return } self.output.string = translation self.setStatus("Japanese · Select text to copy (⌘C).") @@ -241,4 +247,52 @@ final class TranslationController: NSViewController, NSTextFieldDelegate, NSText self.translateButton.isEnabled = true } } + + @MainActor + struct Credentials { + var save: (String) throws -> Void + var load: () throws -> String? + var forget: () throws -> Void + + private static var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "Conjure.OpenAI", + kSecAttrAccount as String: "translation"] + } + + static let keychain = Credentials( + save: { key in + let data = Data(key.utf8) + var result = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if result == errSecItemNotFound { + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + result = SecItemAdd(item as CFDictionary, nil) + } + guard result == errSecSuccess else { throw KeychainError(code: result) } + }, + load: { + var query = Self.query + query[kSecReturnData as String] = true + var item: CFTypeRef? + let result = SecItemCopyMatching(query as CFDictionary, &item) + if result == errSecItemNotFound { return nil } + guard result == errSecSuccess, let data = item as? Data, + let key = String(data: data, encoding: .utf8) else { throw KeychainError(code: result) } + return key + }, + forget: { + let result = SecItemDelete(query as CFDictionary) + guard result == errSecSuccess || result == errSecItemNotFound else { throw KeychainError(code: result) } + } + ) + } + + private struct KeychainError: LocalizedError { + let code: OSStatus + var errorDescription: String? { + "Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))" + } + } } diff --git a/Tests/ConjureKitTests/TranslationExecutionTests.swift b/Tests/ConjureKitTests/TranslationExecutionTests.swift new file mode 100644 index 0000000..b417668 --- /dev/null +++ b/Tests/ConjureKitTests/TranslationExecutionTests.swift @@ -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] = [] + 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) +}