Files
conjure/Sources/Conjure/TranslationController.swift
T

268 lines
12 KiB
Swift

import AppKit
import ConjureKit
import Security
@MainActor
final class TranslationController: NSViewController {
let providerPicker = NSPopUpButton()
private var usesMyMemory: Bool { providerPicker.indexOfSelectedItem == 0 }
private let output = NSTextView()
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<Void, Never>?
var input: (() -> String)?
var closed: (() -> Void)?
var dismiss: (() -> Void)?
private let pasteboard: NSPasteboard
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) },
pasteboard: NSPasteboard = .general
) {
self.defaults = defaults
self.credentialsStore = credentials
self.myMemory = myMemory
self.openAI = openAI
self.pasteboard = pasteboard
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
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(performPrimaryAction)
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, settingsButton, 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
status.font = .systemFont(ofSize: 12)
status.setAccessibilityLabel("Translation status and errors")
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let stack = NSStackView(views: [title, scroll])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
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),
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
])
inputChanged()
}
@objc private func openSettings() {
NSApp.sendAction(NSSelectorFromString("showSettings:"), to: NSApp.delegate, from: self)
}
@objc private func credentialsChanged() { inputChanged() }
@objc func goBack() {
cancelRequest()
closed?()
}
func inputChanged() {
cancelRequest()
output.string = ""
translateButton.title = "Translate"
translateButton.toolTip = "Translate the English input (Return). Escape dismisses Conjure."
settingsButton.isHidden = true
setStatus("Press Return to translate.")
}
@objc private func providerChanged() {
defaults.set(usesMyMemory ? "mymemory" : "openai", forKey: "translationProvider")
inputChanged()
}
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 performPrimaryAction() {
guard task == nil else { return }
guard !output.string.isEmpty else { translate(); return }
pasteboard.clearContents()
if pasteboard.setString(output.string, forType: .string) {
dismiss?()
} else {
setStatus("Could not copy the translation. Try again.", isError: true)
}
}
@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 {
do {
guard let savedKey = try credentialsStore.load() else {
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
}
}
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("English → Japanese")
self.translateButton.title = "Copy"
self.translateButton.toolTip = "Copy the translation and close Conjure (Return)."
} 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 }
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))"
}
}
}