Files
Jarvis/App/Wizard/WizardState.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

119 lines
4.9 KiB
Swift

import SwiftUI
import Keychain
import Shell
public enum WizardStage: String, CaseIterable, Sendable {
case apiKey, tcc, hotkey, complete
}
/// D-09 stateless re-entry: the wizard re-reads the current resolution state
/// on each open from the actual system sources of truth, so re-entering via
/// Setup… or relaunching after a System-Settings grant doesn't depend on
/// stale in-memory state.
///
/// Sources of truth on `refresh()`:
/// - `apiKeyStored` ← Keychain (`KeychainItem.anthropic`)
/// - `inputMonitoringGranted` ← `IOHIDCheckAccess` (query-only, no prompt)
/// - `hotkey` ← UserDefaults (`hotkey.json`)
///
/// `inputMonitoringProbed` is a UI-only flag that toggles to true once the
/// user has clicked "Grant Access" within this session, so the wizard can
/// distinguish "haven't asked yet" from "asked, denied, here's the link to
/// System Settings". On a fresh session it starts false but is upgraded to
/// true immediately if `inputMonitoringGranted` is already true (no point
/// asking again — the user has already granted in a prior session).
@MainActor
public final class WizardState: ObservableObject {
@Published public var currentStage: WizardStage = .apiKey
@Published public var apiKeyStored: Bool = false
@Published public var inputMonitoringProbed: Bool = false
@Published public var inputMonitoringGranted: Bool = false
@Published public var hotkey: Shell.KeyboardShortcut? {
didSet { persistHotkey(hotkey, key: Self.hotkeyDefaultsKey) }
}
/// Issue #87 / VOICE-13 — push-to-talk hotkey. Ships unbound (nil) by
/// default; the user binds one via Settings. Persisted under a
/// distinct UserDefaults key from `hotkey` so the summon-Jarvis
/// shortcut and the PTT shortcut are independent.
///
/// No default value: CLAUDE.md hotkey-hygiene guidance forbids
/// pre-binding because Cmd+Shift+J collides with Chrome/Slack/VSCode
/// and Option+Space collides with Alfred/Raycast. The user picks one
/// that doesn't conflict with their setup.
@Published public var pttHotkey: Shell.KeyboardShortcut? {
didSet { persistHotkey(pttHotkey, key: Self.pttHotkeyDefaultsKey) }
}
private let keychain: any KeychainStore
private let hidProbe: any HIDAccessProbe
private let userDefaults: UserDefaults
/// UserDefaults key for the persisted hotkey. JSON-encoded blob under
/// the standard registered domain so it survives quit/reopen.
private static let hotkeyDefaultsKey = "Jarvis.hotkey"
/// Issue #87: PTT hotkey lives under a separate key from `hotkey`.
private static let pttHotkeyDefaultsKey = "Jarvis.pttHotkey"
public init(
keychain: any KeychainStore = SystemKeychainStore(),
hidProbe: any HIDAccessProbe = SystemHIDAccessProbe(),
userDefaults: UserDefaults = .standard
) {
self.keychain = keychain
self.hidProbe = hidProbe
self.userDefaults = userDefaults
refresh()
}
/// Re-read current truth. Sets `currentStage` to the first unresolved
/// stage so re-entry via Setup… or a quit/reopen lands the user where
/// they left off — including landing past TCC if they granted in
/// System Settings between sessions, or past Hotkey if they bound one
/// in a prior session.
public func refresh() {
apiKeyStored = (try? keychain.get(.anthropic)) != nil
// Query-only TCC check — never prompts. If the user previously
// granted (in this session or a prior one, including via System
// Settings), this returns true and we can skip the TCC stage.
inputMonitoringGranted = hidProbe.isListenEventAccessGranted()
// If already granted, treat as "probed" so the wizard skips straight
// to the next unresolved stage instead of asking again.
if inputMonitoringGranted {
inputMonitoringProbed = true
}
hotkey = loadPersistedHotkey(key: Self.hotkeyDefaultsKey)
pttHotkey = loadPersistedHotkey(key: Self.pttHotkeyDefaultsKey)
currentStage = firstUnresolvedStage
}
public var firstUnresolvedStage: WizardStage {
if !apiKeyStored { return .apiKey }
if !inputMonitoringGranted { return .tcc }
if hotkey == nil { return .hotkey }
return .complete
}
// MARK: - Hotkey persistence
private func loadPersistedHotkey(key: String) -> Shell.KeyboardShortcut? {
guard let data = userDefaults.data(forKey: key) else {
return nil
}
return try? JSONDecoder().decode(Shell.KeyboardShortcut.self, from: data)
}
private func persistHotkey(_ shortcut: Shell.KeyboardShortcut?, key: String) {
guard let shortcut else {
userDefaults.removeObject(forKey: key)
return
}
if let data = try? JSONEncoder().encode(shortcut) {
userDefaults.set(data, forKey: key)
}
}
}