284 lines
12 KiB
Swift
284 lines
12 KiB
Swift
import AppKit
|
|
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 }
|
|
private let output = NSTextView()
|
|
private let disclosure = NSTextField(wrappingLabelWithString: "")
|
|
private let status = NSTextField(wrappingLabelWithString: "")
|
|
private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
|
|
private var task: Task<Void, Never>?
|
|
var input: (() -> String)?
|
|
var closed: (() -> Void)?
|
|
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()
|
|
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
|
|
translateButton.target = self
|
|
translateButton.action = #selector(translate)
|
|
translateButton.keyEquivalent = "\r"
|
|
translateButton.isBordered = false
|
|
translateButton.font = .systemFont(ofSize: 12, weight: .medium)
|
|
translateButton.contentTintColor = SunsetPalette.text
|
|
translateButton.toolTip = "Translate the English input (Return). Escape dismisses Conjure."
|
|
let returnKey = ShortcutIndicator("↵")
|
|
let actionPill = NSView()
|
|
actionPill.wantsLayer = true
|
|
actionPill.layer?.cornerRadius = 18
|
|
actionPill.layer?.borderWidth = 0.5
|
|
actionPill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
|
|
actionPill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
|
|
for control in [translateButton, returnKey] {
|
|
control.translatesAutoresizingMaskIntoConstraints = false
|
|
actionPill.addSubview(control)
|
|
}
|
|
NSLayoutConstraint.activate([
|
|
actionPill.heightAnchor.constraint(equalToConstant: 36),
|
|
translateButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14),
|
|
translateButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
|
returnKey.leadingAnchor.constraint(equalTo: translateButton.trailingAnchor, constant: 8),
|
|
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
|
returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
|
|
])
|
|
let footer = NSStackView(views: [status, NSView(), actionPill])
|
|
footer.orientation = .horizontal
|
|
footer.alignment = .centerY
|
|
footer.spacing = 12
|
|
footer.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(footer)
|
|
output.isEditable = false
|
|
output.isSelectable = true
|
|
output.drawsBackground = false
|
|
output.textColor = SunsetPalette.text
|
|
output.font = .systemFont(ofSize: 20)
|
|
output.setAccessibilityLabel("Japanese translation")
|
|
let scroll = NSScrollView()
|
|
scroll.documentView = output
|
|
scroll.hasVerticalScroller = true
|
|
scroll.drawsBackground = false
|
|
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])
|
|
stack.orientation = .vertical
|
|
stack.alignment = .leading
|
|
stack.spacing = 12
|
|
stack.setCustomSpacing(4, after: provider)
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
|
stack.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12),
|
|
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
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()
|
|
}
|
|
|
|
func controlTextDidChange(_ notification: Notification) { inputChanged() }
|
|
|
|
@objc func goBack() {
|
|
cancelRequest()
|
|
closed?()
|
|
}
|
|
|
|
func inputChanged() {
|
|
cancelRequest()
|
|
output.string = ""
|
|
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
|
|
translateButton.isEnabled = true
|
|
}
|
|
|
|
private func setStatus(_ message: String, isError: Bool = false) {
|
|
status.stringValue = message
|
|
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
|
|
}
|
|
|
|
@objc func translate() {
|
|
guard task == nil else { return }
|
|
let text = input?() ?? ""
|
|
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
setStatus("Enter English text in the search bar.", isError: true)
|
|
return
|
|
}
|
|
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)
|
|
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
|
|
? myMemoryTranslate(text)
|
|
: openAITranslate(text, key)
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.output.string = translation
|
|
self.setStatus("Japanese · Select text to copy (⌘C).")
|
|
} catch {
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.setStatus("Translation failed: \(error.localizedDescription)", isError: true)
|
|
}
|
|
guard let self else { return }
|
|
self.task = nil
|
|
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))"
|
|
}
|
|
}
|
|
}
|