Files
conjure/Sources/Conjure/TranslationController.swift
T
2026-10-15 10:24:39 +02:00

154 lines
6.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import ConjureKit
import Security
@MainActor
final class TranslationController: NSViewController {
private let keyField = NSSecureTextField()
private let output = NSTextView()
private let status = NSTextField(wrappingLabelWithString: "English → Japanese · gpt-6-luna · Text is sent to OpenAI only when you translate.")
private let translateButton = NSButton(title: "Translate ↵", target: nil, action: nil)
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",
]
override func loadView() {
view = NSView()
let back = NSButton(title: "‹ Back", target: self, action: #selector(goBack))
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
title.font = .systemFont(ofSize: 13, weight: .semibold)
let heading = NSStackView(views: [back, title, NSView()])
keyField.placeholderString = "OpenAI API key (saved in Keychain)"
keyField.setAccessibilityLabel("OpenAI API key")
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))
let credentials = NSStackView(views: [keyField, save, forget])
credentials.distribution = .fill
translateButton.target = self
translateButton.action = #selector(translate)
translateButton.keyEquivalent = "\r"
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.textColor = SunsetPalette.secondaryText
status.font = .systemFont(ofSize: 12)
let stack = NSStackView(views: [heading, credentials, scroll, status, translateButton])
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: view.bottomAnchor, constant: -16),
credentials.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
status.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
}
@objc func goBack() {
task?.cancel()
task = nil
closed?()
}
func inputChanged() {
task?.cancel()
task = nil
translateButton.isEnabled = true
output.string = ""
status.stringValue = "Press Return to translate English → Japanese with gpt-6-luna."
}
@objc private func saveKey() {
let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else {
status.stringValue = "Enter a valid API key before saving."
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 {
keyField.stringValue = ""
status.stringValue = "API key saved in Keychain. Press Return to translate."
} else { showKeychainError(result) }
}
@objc private func forgetKey() {
task?.cancel()
task = nil
translateButton.isEnabled = true
let result = SecItemDelete(Self.credential as CFDictionary)
if result == errSecSuccess || result == errSecItemNotFound {
keyField.stringValue = ""
status.stringValue = "API key removed."
} else { showKeychainError(result) }
}
private func showKeychainError(_ code: OSStatus) {
status.stringValue = "Keychain: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))"
}
@objc func translate() {
guard task == nil else { return }
let text = input?() ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
status.stringValue = "Enter English text in the search bar."
return
}
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 key = String(data: data, encoding: .utf8) else {
if result == errSecItemNotFound { status.stringValue = "Enter and save your OpenAI API key first." }
else { showKeychainError(result) }
return
}
output.string = ""
status.stringValue = "Translating…"
translateButton.isEnabled = false
task = Task { [weak self] in
do {
let translation = try await TranslationAPI.translate(text, apiKey: key)
guard !Task.isCancelled, let self else { return }
self.output.string = translation
self.status.stringValue = "Japanese · Select text to copy (⌘C)."
} catch {
guard !Task.isCancelled, let self else { return }
self.status.stringValue = error.localizedDescription
}
guard let self else { return }
self.task = nil
self.translateButton.isEnabled = true
}
}
}