345 lines
14 KiB
Swift
345 lines
14 KiB
Swift
import AppKit
|
|
import ConjureKit
|
|
import ServiceManagement
|
|
|
|
@MainActor
|
|
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|
private let shortcut: GlobalShortcut
|
|
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)
|
|
|
|
init(shortcut: GlobalShortcut) {
|
|
self.shortcut = shortcut
|
|
let window = ShortcutSettingsWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 620, height: 640),
|
|
styleMask: [.titled, .closable, .miniaturizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Conjure Settings"
|
|
window.appearance = NSAppearance(named: .darkAqua)
|
|
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 Shortcut")
|
|
heading.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
|
|
|
|
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 = "Press a key together with Command, Option, or Control. Escape cancels."
|
|
}
|
|
let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, 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:
|
|
"Click the shortcut button, then press a key with Command, Option, or Control. Press Escape to cancel. The global shortcut is paused while recording."
|
|
)
|
|
instructions.textColor = .secondaryLabelColor
|
|
statusLabel.textColor = .secondaryLabelColor
|
|
statusLabel.setAccessibilityLabel("Global shortcut registration status")
|
|
errorLabel.textColor = .systemRed
|
|
errorLabel.setAccessibilityLabel("Shortcut 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")
|
|
|
|
heading.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
heading.textColor = SunsetPalette.text
|
|
for label in [instructions, statusLabel, spotlight, loginStatus] {
|
|
label.textColor = SunsetPalette.secondaryText
|
|
}
|
|
for button in [recorder, commandSpaceButton, clearButton, keyboardSettings, loginToggle, loginSettings] {
|
|
button.contentTintColor = SunsetPalette.accent
|
|
}
|
|
let loginHeading = NSTextField(labelWithString: "Startup")
|
|
loginHeading.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
loginHeading.textColor = SunsetPalette.text
|
|
let divider = NSBox()
|
|
divider.boxType = .separator
|
|
|
|
let stack = NSStackView(views: [
|
|
heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings,
|
|
divider, loginHeading, loginToggle, loginStatus, loginSettings
|
|
])
|
|
stack.orientation = .vertical
|
|
stack.alignment = .leading
|
|
stack.spacing = 12
|
|
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)
|
|
content.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24),
|
|
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
|
|
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
|
stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24),
|
|
recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190)
|
|
])
|
|
for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus] {
|
|
label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
}
|
|
}
|
|
|
|
private func refreshLogin() {
|
|
let status = SMAppService.mainApp.status
|
|
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."
|
|
}
|
|
}
|
|
|
|
@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)"
|
|
}
|
|
}
|
|
|
|
@objc private func openLoginSettings() {
|
|
cancelRecording()
|
|
SMAppService.openSystemSettingsLoginItems()
|
|
}
|
|
|
|
private func refresh() {
|
|
refreshLogin()
|
|
let name = shortcut.shortcut?.displayName
|
|
recorder.title = recorder.isRecording ? "Type shortcut…" : (name ?? "Record Shortcut…")
|
|
recorder.setAccessibilityValue(name ?? "Not set")
|
|
if recorder.isRecording {
|
|
statusLabel.stringValue = "Global shortcut 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 !recorder.isRecording else { return }
|
|
errorLabel.stringValue = ""
|
|
shortcut.suspend()
|
|
recorder.isRecording = true
|
|
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 {
|
|
recorder.isRecording = false
|
|
errorLabel.stringValue = "Choose a supported key with Command, Option, or Control. A modifier by itself is not a shortcut."
|
|
restoreSavedShortcut()
|
|
refresh()
|
|
return
|
|
}
|
|
apply(candidate)
|
|
}
|
|
|
|
private func apply(_ candidate: KeyboardShortcut?) {
|
|
let wasRecording = recorder.isRecording
|
|
recorder.isRecording = false
|
|
errorLabel.stringValue = ""
|
|
do {
|
|
try shortcut.setShortcut(candidate)
|
|
} catch {
|
|
errorLabel.stringValue = error.localizedDescription
|
|
if wasRecording { restoreSavedShortcut() }
|
|
}
|
|
refresh()
|
|
}
|
|
|
|
private func cancelRecording() {
|
|
guard recorder.isRecording else { return }
|
|
recorder.isRecording = false
|
|
errorLabel.stringValue = ""
|
|
restoreSavedShortcut()
|
|
refresh()
|
|
}
|
|
|
|
private func restoreSavedShortcut() {
|
|
do {
|
|
try shortcut.setShortcut(shortcut.shortcut)
|
|
} catch {
|
|
let message = "Could not reactivate the saved shortcut: \(error.localizedDescription)"
|
|
errorLabel.stringValue = errorLabel.stringValue.isEmpty ? message : "\(errorLabel.stringValue)\n\(message)"
|
|
}
|
|
}
|
|
|
|
@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
|
|
}
|
|
}
|