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