Files
Jarvis/App/Settings/SettingsWindowController.swift
james.maes 795c3bf5c9 feat(app): v0.1 UX surfaces — startup panels + InputMonitoring banner + PTT (#87, #88, #89, #156)
#89: new AppDelegate.startupSurfacesMode enum (.minimal/.diagnostic). Debug defaults .diagnostic; Release defaults .minimal. openStartupSurfacesIfDiagnostic() runs at tail of bootHealthTask (skipped under XCTest or when wizard is open). Status panel top-right + Dev Overlay bottom-right of NSScreen.main.visibleFrame. DevOverlay opens to Logs tab.

#88: BannerContent.inputMonitoringDenied is now nonDismissible: true. New HUDBannerCoordinator.dismiss(id:) clears a banner programmatically without writing dismissedThisLaunch. AppDelegate.reprobeInputMonitoring fires on NSApplication.didBecomeActiveNotification; rebinds the global hotkey on re-grant. Uses IOHIDCheckAccess (query-only, no prompt) per CLAUDE.md guidance. 3 new coordinator tests.

#87: WizardState.pttHotkey persisted under Jarvis.pttHotkey (independent of summon hotkey). Ships unbound per CLAUDE.md hotkey hygiene. AppDelegate.rebindPTTFromState() called from installVoice, foreground TCC re-grant, Settings Save/Clear. SettingsView extended with PTT hotkey section. Audio-graph isolation: NSEvent monitors only, no audio-graph surgery — safe alongside fix/voice-crit-v0.1. 3 new WizardState tests.

DevOverlay 14 + Voice 26 tests pass; all 17 grep boundary gates green (incl. single-writer-hudstate + no-modal-presentation).
2026-05-13 06:32:16 -05:00

73 lines
2.5 KiB
Swift

import AppKit
import SwiftUI
import Keychain
import Shell
/// Hosts the `SettingsView` in an `NSWindow`. Distinct from the first-run
/// wizard:
///
/// Setup… → `OnboardingWizardController` (sequential first-run flow)
/// Settings… → `SettingsWindowController` (this — freeform settings panel)
///
/// Both share the same `WizardState` because the canonical truth lives there
/// (Keychain for the API key, IOHIDCheckAccess for input monitoring,
/// UserDefaults `Jarvis.hotkey` for the global shortcut). Edits in either
/// surface write through to those stores so the other surface sees current
/// values on next open.
///
/// Window discipline mirrors `OnboardingWizardController`: a single reusable
/// window, brought forward and refreshed on each `open(...)`. No stacking.
@MainActor
public final class SettingsWindowController {
public let state: WizardState
private var window: NSWindow?
public init(state: WizardState) {
self.state = state
}
public func open(
keychain: any KeychainStore,
validator: AnthropicKeyValidator,
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in }
) {
// Refresh truth before view creation: TCC grants and Keychain entries
// can change between sessions, same reasoning as the wizard's
// refresh-on-open in `OnboardingWizardController`.
state.refresh()
if let existing = window, existing.isVisible {
existing.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let rootView = SettingsView(
state: state,
keychain: keychain,
validator: validator,
onGrantInputMonitoring: onGrantInputMonitoring,
onHotkeyChanged: onHotkeyChanged,
onPTTHotkeyChanged: onPTTHotkeyChanged,
onClose: { [weak self] in self?.close() }
)
let hosting = NSHostingController(rootView: rootView)
let window = NSWindow(contentViewController: hosting)
window.styleMask = [.titled, .closable, .miniaturizable]
window.title = "Settings — Jarvis"
window.isReleasedWhenClosed = false
self.window = window
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
public func close() {
window?.close()
window = nil
}
}