Files
conjure/Sources/Conjure/SettingsWindowController.swift

437 lines
19 KiB
Swift

import AppKit
import ConjureKit
import ServiceManagement
@MainActor
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private let shortcuts: GlobalShortcuts
private let translationSettings = TranslationSettingsController()
private let destinationSelector = NSPopUpButton(frame: .zero, pullsDown: false)
private let commandSpaceButton = NSButton(title: "Use ⌘Space", target: nil, action: nil)
private var shortcut: GlobalShortcut { shortcuts.bindings[destinationSelector.indexOfSelectedItem] }
private let recorder = ShortcutRecorderButton(title: "Record Shortcut…", target: nil, action: nil)
private let clearButton = NSButton(title: "Clear", target: nil, action: nil)
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let errorLabel = NSTextField(wrappingLabelWithString: "")
private let loginToggle = NSButton(checkboxWithTitle: "Launch at login", target: nil, action: nil)
private let loginStatus = NSTextField(wrappingLabelWithString: "")
private let loginSettings = NSButton(title: "Open Login Items Settings…", target: nil, action: nil)
private let recoveryLabel = NSTextField(wrappingLabelWithString: "")
private let recoveryButton = NSButton(title: "Retry uninstall recovery…", target: nil, action: nil)
private var recoveryReport: UninstallReport?
private var themeObservation: ThemeObservation?
private var loginStatusIsError = false
var retryUninstallRecovery: (() async -> UninstallReport)?
init(shortcuts: GlobalShortcuts) {
self.shortcuts = shortcuts
let window = ShortcutSettingsWindow(
contentRect: NSRect(x: 0, y: 0, width: 620, height: 690),
styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "Conjure Settings"
window.appearance = ThemePalette.appearance
window.titlebarAppearsTransparent = true
window.backgroundColor = .clear
window.isOpaque = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.center()
super.init(window: window)
window.delegate = self
window.recorder = recorder
configureContent(in: window)
refresh()
}
required init?(coder: NSCoder) {
fatalError("Conjure creates its settings window programmatically")
}
func present(error: String? = nil) {
cancelRecording()
if let error {
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? error : "\(error)\n\(errorLabel.stringValue)"
}
refresh()
window?.deminiaturize(nil)
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
}
func windowDidBecomeKey(_ notification: Notification) {
refreshLogin()
}
func windowDidResignKey(_ notification: Notification) {
cancelRecording()
}
func windowWillClose(_ notification: Notification) {
cancelRecording()
}
private func configureContent(in window: NSWindow) {
let heading = NSTextField(labelWithString: "Global Shortcuts")
heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
destinationSelector.addItems(withTitles: GlobalShortcuts.destinations.map(\.title))
destinationSelector.target = self
destinationSelector.action = #selector(changeDestination)
destinationSelector.setAccessibilityLabel("Global shortcut destination")
recorder.bezelStyle = .rounded
recorder.target = self
recorder.action = #selector(beginRecording)
recorder.setAccessibilityLabel("Record global shortcut")
recorder.capture = { [weak self] event in self?.capture(event) }
recorder.cancel = { [weak self] in self?.cancelRecording() }
recorder.modifiersChanged = { [weak self] in
self?.errorLabel.stringValue = ""
self?.statusLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
}
commandSpaceButton.target = self
commandSpaceButton.action = #selector(useCommandSpace)
commandSpaceButton.bezelStyle = .rounded
commandSpaceButton.setAccessibilityLabel("Use Command Space as the global shortcut")
clearButton.bezelStyle = .rounded
clearButton.target = self
clearButton.action = #selector(clearShortcut)
clearButton.setAccessibilityLabel("Clear global shortcut")
let controls = NSStackView(views: [recorder, commandSpaceButton, clearButton])
controls.orientation = .horizontal
controls.alignment = .centerY
controls.spacing = 8
let instructions = NSTextField(wrappingLabelWithString:
"Choose a destination, click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. All global shortcuts are paused while recording."
)
instructions.textColor = .secondaryLabelColor
statusLabel.textColor = .secondaryLabelColor
statusLabel.setAccessibilityLabel("Global shortcut registration status")
errorLabel.textColor = ThemePalette.error
errorLabel.setAccessibilityLabel("Settings feedback")
let spotlight = NSTextField(wrappingLabelWithString:
"To use ⌘Space, first free it in System Settings → Keyboard → Keyboard Shortcuts → Spotlight: turn off or change “Show Spotlight search.” Check Input Sources for other reserved shortcuts. Conjure never changes these settings."
)
spotlight.textColor = .secondaryLabelColor
let keyboardSettings = NSButton(
title: "Open Keyboard Settings…", target: self, action: #selector(openKeyboardSettings)
)
keyboardSettings.bezelStyle = .rounded
loginToggle.target = self
loginToggle.action = #selector(toggleLogin)
loginSettings.target = self
loginSettings.action = #selector(openLoginSettings)
loginSettings.bezelStyle = .rounded
loginStatus.setAccessibilityLabel("Launch at login status")
recoveryLabel.textColor = ThemePalette.secondaryText
recoveryLabel.isHidden = true
recoveryLabel.setAccessibilityLabel("Uninstall recovery status")
recoveryButton.isHidden = true
recoveryButton.bezelStyle = .rounded
recoveryButton.target = self
recoveryButton.action = #selector(showUninstallRecovery)
heading.font = .systemFont(ofSize: 20, weight: .semibold)
heading.textColor = ThemePalette.text
for label in [instructions, statusLabel, spotlight, loginStatus] {
label.textColor = ThemePalette.secondaryText
}
for button in [recorder, commandSpaceButton, clearButton, keyboardSettings, loginToggle, loginSettings, recoveryButton] {
button.contentTintColor = ThemePalette.accent
}
let loginHeading = NSTextField(labelWithString: "Startup")
loginHeading.font = .systemFont(ofSize: 20, weight: .semibold)
loginHeading.textColor = ThemePalette.text
let translationHeading = NSTextField(labelWithString: "Conjure Translate")
translationHeading.font = .systemFont(ofSize: 20, weight: .semibold)
translationHeading.textColor = ThemePalette.text
let stack = NSStackView(views: [
heading, destinationSelector, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton,
translationHeading, translationSettings.view
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.setCustomSpacing(32, after: keyboardSettings)
stack.setCustomSpacing(32, after: recoveryButton)
stack.translatesAutoresizingMaskIntoConstraints = false
let content = NSVisualEffectView()
content.material = .hudWindow
content.blendingMode = .behindWindow
content.state = .active
window.contentView = content
let background = LauncherBackground(frame: content.bounds)
background.autoresizingMask = [.width, .height]
background.setAccessibilityElement(false)
content.addSubview(background)
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
scroll.translatesAutoresizingMaskIntoConstraints = false
let document = SettingsDocumentView()
document.translatesAutoresizingMaskIntoConstraints = false
document.addSubview(stack)
scroll.documentView = document
content.addSubview(scroll)
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor),
scroll.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor),
scroll.bottomAnchor.constraint(equalTo: content.bottomAnchor),
document.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
stack.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -24),
stack.topAnchor.constraint(equalTo: document.topAnchor, constant: 24),
stack.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -24),
translationSettings.view.widthAnchor.constraint(equalTo: stack.widthAnchor),
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
])
for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] {
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
themeObservation = ThemeObservation { [weak self] in
guard let self else { return }
self.window?.appearance = ThemePalette.appearance
self.window?.attachedSheet?.appearance = ThemePalette.appearance
for label in [heading, loginHeading, translationHeading] { label.textColor = ThemePalette.text }
for label in [instructions, self.statusLabel, spotlight] { label.textColor = ThemePalette.secondaryText }
self.errorLabel.textColor = ThemePalette.error
self.loginStatus.textColor = self.loginStatusIsError ? ThemePalette.error : ThemePalette.secondaryText
self.recoveryLabel.textColor = self.recoveryReport?.needsRecovery == true ? ThemePalette.error : ThemePalette.secondaryText
for button in [self.recorder, self.commandSpaceButton, self.clearButton, keyboardSettings,
self.loginToggle, self.loginSettings, self.recoveryButton] {
button.contentTintColor = ThemePalette.accent
}
}
}
func setUninstallRecoveryReport(_ report: UninstallReport) {
recoveryReport = report
recoveryLabel.isHidden = report.messages.isEmpty && !report.needsRecovery
recoveryButton.isHidden = recoveryLabel.isHidden
recoveryLabel.stringValue = report.needsRecovery
? "An interrupted uninstall needs attention. Files have been retained for recovery."
: "Interrupted uninstall recovery completed."
recoveryLabel.textColor = report.needsRecovery ? ThemePalette.error : ThemePalette.secondaryText
recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…"
}
@objc private func showUninstallRecovery() {
guard let report = recoveryReport, let window else { return }
recoveryButton.isEnabled = false
Task {
let result: UninstallReport
if report.needsRecovery, let retryUninstallRecovery {
result = await retryUninstallRecovery()
setUninstallRecoveryReport(result)
} else { result = report }
let alert = NSAlert()
alert.window.appearance = ThemePalette.appearance
alert.messageText = result.needsRecovery ? "Recovery needs attention" : "Recovery complete"
alert.informativeText = result.messages.isEmpty ? "No pending files need recovery." : result.messages.joined(separator: "\n")
await alert.beginSheetModal(for: window)
recoveryButton.isEnabled = true
}
}
private func refreshLogin() {
let status = SMAppService.mainApp.status
loginStatus.textColor = ThemePalette.secondaryText
loginStatusIsError = false
loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off
loginSettings.isHidden = status != .requiresApproval
switch status {
case .enabled:
loginStatus.stringValue = "Conjure starts in the background when you log in."
case .requiresApproval:
loginStatus.stringValue = "Allow Conjure in System Settings → General → Login Items & Extensions."
case .notRegistered:
loginStatus.stringValue = "Start Conjure automatically so your global shortcut is available."
case .notFound:
loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login."
@unknown default:
loginStatus.stringValue = "Launch at login status is unavailable."
loginStatus.textColor = ThemePalette.error
loginStatusIsError = true
}
}
@objc private func toggleLogin() {
cancelRecording()
do {
if loginToggle.state == .on {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
refreshLogin()
} catch {
refreshLogin()
loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)"
loginStatus.textColor = ThemePalette.error
loginStatusIsError = true
}
}
@objc private func openLoginSettings() {
cancelRecording()
SMAppService.openSystemSettingsLoginItems()
}
private func refresh() {
refreshLogin()
recorder.isRecording = shortcuts.isRecording
commandSpaceButton.isHidden = destinationSelector.indexOfSelectedItem != 0
recorder.setAccessibilityLabel("Record \(GlobalShortcuts.destinations[destinationSelector.indexOfSelectedItem].title) global shortcut")
let name = shortcut.shortcut?.displayName
recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…")
recorder.setAccessibilityValue(name ?? "Not set")
if recorder.isRecording {
statusLabel.stringValue = "All global shortcuts are paused while recording."
} else if shortcut.isRegistered {
statusLabel.stringValue = "Global shortcut is active."
} else if shortcut.shortcut != nil {
statusLabel.stringValue = "This shortcut is saved but is not active."
} else {
statusLabel.stringValue = "No global shortcut is configured."
}
}
@objc private func beginRecording() {
guard !shortcuts.isRecording else { return }
errorLabel.stringValue = ""
shortcuts.beginRecording()
refresh()
if window?.makeFirstResponder(recorder) != true {
cancelRecording()
}
}
private func capture(_ event: NSEvent) {
guard !event.isARepeat else { return }
if event.keyCode == 53, event.modifierFlags.intersection([.command, .option, .control]).isEmpty {
cancelRecording()
return
}
guard let candidate = KeyboardShortcut(keyCode: event.keyCode, modifiers: event.modifierFlags) else {
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
appendShortcutErrors(shortcuts.cancelRecording())
refresh()
return
}
apply(candidate)
}
private func apply(_ candidate: KeyboardShortcut?) {
errorLabel.stringValue = ""
appendShortcutErrors(shortcuts.finishRecording(candidate, at: destinationSelector.indexOfSelectedItem))
refresh()
}
private func cancelRecording() {
guard shortcuts.isRecording else { return }
errorLabel.stringValue = ""
appendShortcutErrors(shortcuts.cancelRecording())
refresh()
}
private func appendShortcutErrors(_ errors: [String]) {
if !errors.isEmpty {
let message = errors.joined(separator: "\n")
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
}
}
@objc private func changeDestination() {
cancelRecording()
refresh()
}
@objc private func useCommandSpace() {
apply(.commandSpace)
}
@objc private func clearShortcut() {
apply(nil)
}
@objc private func openKeyboardSettings() {
cancelRecording()
let url = URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!
if !NSWorkspace.shared.open(url) {
errorLabel.stringValue = "Could not open Keyboard settings. Open System Settings → Keyboard manually."
}
}
}
@MainActor
private final class ShortcutSettingsWindow: NSWindow {
weak var recorder: ShortcutRecorderButton?
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if let recorder, recorder.isRecording, firstResponder === recorder {
return recorder.performKeyEquivalent(with: event)
}
return super.performKeyEquivalent(with: event)
}
}
@MainActor
private final class ShortcutRecorderButton: NSButton {
var isRecording = false
var capture: ((NSEvent) -> Void)?
var cancel: (() -> Void)?
var modifiersChanged: (() -> Void)?
override var acceptsFirstResponder: Bool { true }
override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard isRecording, window?.firstResponder === self else {
return super.performKeyEquivalent(with: event)
}
keyDown(with: event)
return true
}
override func keyDown(with event: NSEvent) {
guard isRecording else {
super.keyDown(with: event)
return
}
let activationModifiers: NSEvent.ModifierFlags = [.command, .option, .control]
if event.keyCode == 48, event.modifierFlags.intersection(activationModifiers).isEmpty {
if event.modifierFlags.contains(.shift) {
window?.selectPreviousKeyView(nil)
} else {
window?.selectNextKeyView(nil)
}
return
}
capture?(event)
}
override func flagsChanged(with event: NSEvent) {
if isRecording { modifiersChanged?() } else { super.flagsChanged(with: event) }
}
override func resignFirstResponder() -> Bool {
let resigned = super.resignFirstResponder()
if resigned, isRecording { cancel?() }
return resigned
}
}
private final class SettingsDocumentView: NSView {
override var isFlipped: Bool { true }
}