154 lines
6.8 KiB
Swift
154 lines
6.8 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|