#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).
391 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|