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? 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))" } } }