Files
Jarvis/App/Settings/SettingsView.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

391 lines
15 KiB
Swift

import SwiftUI
import Keychain
import Shell
/// Real Settings panel — single window, three rows, every row independently
/// editable. Distinct from the first-run wizard:
///
/// Setup… → sequential first-run flow (Stage 1 → 2 → 3, terminal Save)
/// Settings… → freeform settings panel (this view)
///
/// The wizard is correct for fresh installs. This view is correct for
/// later changes. Both share the same underlying state (`WizardState`)
/// because the canonical truth lives there:
/// - apiKeyStored → Keychain
/// - inputMonitoringGranted → IOHIDCheckAccess (query-only)
/// - hotkey → UserDefaults (`Jarvis.hotkey`)
///
/// Any edit here writes through to those stores so the wizard sees the
/// same values on next open.
struct SettingsView: View {
@ObservedObject var state: WizardState
let keychain: any KeychainStore
let validator: AnthropicKeyValidator
let onGrantInputMonitoring: () -> Bool
let onHotkeyChanged: (Shell.KeyboardShortcut?) -> Void
/// Issue #87 — fires when the user binds, clears, or changes the PTT
/// hotkey. Defaults to a no-op so existing callers that don't pass
/// this still compile.
let onPTTHotkeyChanged: (Shell.KeyboardShortcut?) -> Void
let onClose: () -> Void
init(
state: WizardState,
keychain: any KeychainStore,
validator: AnthropicKeyValidator,
onGrantInputMonitoring: @escaping () -> Bool,
onHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void,
onPTTHotkeyChanged: @escaping (Shell.KeyboardShortcut?) -> Void = { _ in },
onClose: @escaping () -> Void
) {
self.state = state
self.keychain = keychain
self.validator = validator
self.onGrantInputMonitoring = onGrantInputMonitoring
self.onHotkeyChanged = onHotkeyChanged
self.onPTTHotkeyChanged = onPTTHotkeyChanged
self.onClose = onClose
}
@State private var apiKeyDraft: String = ""
@State private var apiKeyStatus: APIKeyStatus = .unchanged
@State private var apiKeyEditing: Bool = false
@State private var hotkeyDraft: Shell.KeyboardShortcut?
@State private var hotkeyError: String?
/// Issue #87 — PTT hotkey draft, mirrored from `state.pttHotkey` on
/// `onAppear`. Separate from `hotkeyDraft` so binding one doesn't
/// clobber the other.
@State private var pttHotkeyDraft: Shell.KeyboardShortcut?
@State private var pttHotkeyError: String?
enum APIKeyStatus: Equatable {
case unchanged
case validating
case validButUnsaved
case saved
case invalid(String)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Settings").font(.system(size: 20, weight: .semibold))
apiKeySection
Divider()
permissionsSection
Divider()
hotkeySection
Divider()
pttHotkeySection
Spacer()
HStack {
Spacer()
Button("Done") { onClose() }
.keyboardShortcut(.defaultAction)
}
}
.padding(24)
}
.frame(width: 520, height: 620)
.onAppear {
// Refresh truth on each open — same reasoning as the wizard's
// refresh on open: TCC grants and Keychain entries can change
// between sessions.
state.refresh()
hotkeyDraft = state.hotkey
pttHotkeyDraft = state.pttHotkey
}
}
// MARK: - API key
private var apiKeySection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader("Anthropic API key", subtitle: "Required to reach Claude. Stored in macOS Keychain (never plaintext on disk).")
HStack(spacing: 8) {
if apiKeyEditing {
SecureField("sk-ant-...", text: $apiKeyDraft)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onSubmit { saveAPIKey() }
Button("Save") { saveAPIKey() }
.disabled(apiKeyDraft.isEmpty || apiKeyStatus == .validating)
Button("Cancel") {
apiKeyEditing = false
apiKeyDraft = ""
apiKeyStatus = .unchanged
}
} else {
Image(systemName: state.apiKeyStored
? "checkmark.circle.fill"
: "exclamationmark.circle.fill")
.foregroundColor(state.apiKeyStored
? Color(NSColor.systemGreen)
: Color(NSColor.systemOrange))
Text(state.apiKeyStored ? "Configured" : "Not set")
.frame(maxWidth: .infinity, alignment: .leading)
Button(state.apiKeyStored ? "Change…" : "Set Key…") {
apiKeyEditing = true
apiKeyDraft = ""
apiKeyStatus = .unchanged
}
if state.apiKeyStored {
Button("Remove") { removeAPIKey() }
.foregroundColor(Color(NSColor.systemRed))
}
}
}
apiKeyStatusBanner
}
}
@ViewBuilder
private var apiKeyStatusBanner: some View {
switch apiKeyStatus {
case .unchanged:
EmptyView()
case .validating:
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Validating against Anthropic…")
.foregroundColor(Color(NSColor.secondaryLabelColor))
}
.font(.system(size: 12))
case .validButUnsaved:
Label("Key looks valid — saving to Keychain", systemImage: "checkmark.circle")
.foregroundColor(Color(NSColor.systemGreen))
.font(.system(size: 12))
case .saved:
Label("Saved to Keychain.", systemImage: "lock.shield")
.foregroundColor(Color(NSColor.systemGreen))
.font(.system(size: 12))
case .invalid(let msg):
Label(msg, systemImage: "xmark.octagon.fill")
.foregroundColor(Color(NSColor.systemRed))
.font(.system(size: 12))
}
}
private func saveAPIKey() {
let trimmed = apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
apiKeyStatus = .invalid("Enter a key.")
return
}
apiKeyStatus = .validating
Task {
let result = await validator.validate(key: trimmed)
switch result {
case .valid:
apiKeyStatus = .validButUnsaved
do {
try keychain.set(trimmed, for: .anthropic)
state.refresh()
apiKeyStatus = .saved
apiKeyEditing = false
apiKeyDraft = ""
} catch {
apiKeyStatus = .invalid("Keychain write failed: \(error.localizedDescription)")
}
case .malformed:
apiKeyStatus = .invalid("Key looks malformed (must start with sk-ant-).")
case .unauthorized:
apiKeyStatus = .invalid("Anthropic rejected the key (401). Check it on console.anthropic.com.")
case .network:
apiKeyStatus = .invalid("Couldn't reach Anthropic — check your connection.")
case .generic:
apiKeyStatus = .invalid("Validation failed for an unknown reason.")
}
}
}
private func removeAPIKey() {
do {
try keychain.delete(.anthropic)
state.refresh()
apiKeyStatus = .unchanged
} catch {
apiKeyStatus = .invalid("Couldn't remove key: \(error.localizedDescription)")
}
}
// MARK: - Permissions
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader("Input Monitoring", subtitle: "Required for the global hotkey to fire when another app is frontmost.")
HStack(spacing: 8) {
Image(systemName: state.inputMonitoringGranted
? "checkmark.circle.fill"
: "exclamationmark.circle.fill")
.foregroundColor(state.inputMonitoringGranted
? Color(NSColor.systemGreen)
: Color(NSColor.systemOrange))
Text(state.inputMonitoringGranted ? "Granted" : "Not granted")
.frame(maxWidth: .infinity, alignment: .leading)
if state.inputMonitoringGranted {
Button("Open System Settings") {
openInputMonitoringSettings()
}
} else {
Button("Request") {
state.inputMonitoringGranted = onGrantInputMonitoring()
state.inputMonitoringProbed = true
}
Button("Re-check") { state.refresh() }
Button("Open System Settings") {
openInputMonitoringSettings()
}
}
}
if !state.inputMonitoringGranted {
Text("If Jarvis isn't listed in System Settings, click + and pick Jarvis.app from the build folder.")
.font(.system(size: 11))
.foregroundColor(Color(NSColor.tertiaryLabelColor))
}
}
}
private func openInputMonitoringSettings() {
if let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") {
NSWorkspace.shared.open(url)
}
}
// MARK: - Hotkey
private var hotkeySection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader("Global hotkey", subtitle: "Press to summon Jarvis from any app. Press again (or Esc) to dismiss.")
HStack(spacing: 8) {
ShortcutRecorderView(
shortcut: $hotkeyDraft,
errorMessage: $hotkeyError
)
.frame(height: 36)
.frame(maxWidth: .infinity)
if hotkeyDraft != nil {
Button("Save") {
state.hotkey = hotkeyDraft
onHotkeyChanged(hotkeyDraft)
}
.disabled(hotkeyDraft == state.hotkey)
}
if state.hotkey != nil {
Button("Clear") {
state.hotkey = nil
hotkeyDraft = nil
onHotkeyChanged(nil)
}
.foregroundColor(Color(NSColor.systemRed))
}
}
if let s = state.hotkey {
Text("Current: \(s.displayString)")
.font(.system(size: 12).monospaced())
.foregroundColor(Color(NSColor.secondaryLabelColor))
} else {
Text("No hotkey bound — use the menu-bar icon to summon Jarvis.")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.secondaryLabelColor))
}
if let err = hotkeyError {
Label(err, systemImage: "exclamationmark.triangle")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.systemOrange))
}
if let warn = hotkeyDraft.flatMap(CollisionDetector.check) {
Label(warn.message, systemImage: "exclamationmark.triangle.fill")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.systemOrange))
}
}
}
// MARK: - PTT hotkey (#87 / VOICE-13)
/// Push-to-talk shortcut. Ships unbound — the user must explicitly
/// pick one. Independent of the summon-Jarvis hotkey above; bound to
/// `PushToTalk` via AppDelegate's `rebindPTTFromState`.
private var pttHotkeySection: some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader(
"Push-to-talk hotkey",
subtitle: "Hold to talk; release to submit. Bypasses the wake word. Ships unbound — pick a combo that doesn't collide with your other apps (Cmd+Shift+J, Option+Space are taken on most setups)."
)
HStack(spacing: 8) {
ShortcutRecorderView(
shortcut: $pttHotkeyDraft,
errorMessage: $pttHotkeyError
)
.frame(height: 36)
.frame(maxWidth: .infinity)
if pttHotkeyDraft != nil {
Button("Save") {
state.pttHotkey = pttHotkeyDraft
onPTTHotkeyChanged(pttHotkeyDraft)
}
.disabled(pttHotkeyDraft == state.pttHotkey)
}
if state.pttHotkey != nil {
Button("Clear") {
state.pttHotkey = nil
pttHotkeyDraft = nil
onPTTHotkeyChanged(nil)
}
.foregroundColor(Color(NSColor.systemRed))
}
}
if let s = state.pttHotkey {
Text("Current: \(s.displayString)")
.font(.system(size: 12).monospaced())
.foregroundColor(Color(NSColor.secondaryLabelColor))
} else {
Text("No PTT hotkey bound — voice activates via wake word only.")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.secondaryLabelColor))
}
if let err = pttHotkeyError {
Label(err, systemImage: "exclamationmark.triangle")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.systemOrange))
}
if let warn = pttHotkeyDraft.flatMap(CollisionDetector.check) {
Label(warn.message, systemImage: "exclamationmark.triangle.fill")
.font(.system(size: 12))
.foregroundColor(Color(NSColor.systemOrange))
}
}
}
// MARK: - Helpers
private func sectionHeader(_ title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline)
Text(subtitle)
.font(.system(size: 12))
.foregroundColor(Color(NSColor.secondaryLabelColor))
.fixedSize(horizontal: false, vertical: true)
}
}
}