10 Commits
Author SHA1 Message Date
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
james.maesandClaude Opus 4.7 b92d062f8b fix(tcc): explicit mic + camera permission requests in installVoice/installVision
Live test 2026-05-06 surfaced that the production app never actually triggered
the macOS mic or camera permission dialogs — the wizard's TCC stage was
explainer-only ("(later)") and the "first-use prompt" code that was supposed
to land later was never written. Result: AVAudioEngine opened cleanly, mic
input was silent, no wake-word ever fired, and CameraCapture.open() failed
.cameraDenied without ever asking.

Fix: explicit AVCaptureDevice.requestAccess(for:) calls at the first-use
boundary of each subsystem.

* App/AppDelegate.swift installVoice — checks mic auth status; on
  .notDetermined awaits requestAccess(for: .audio) BEFORE
  graphOwner.open(); on .denied/.restricted logs and returns dormant. Voice
  loop only proceeds with confirmed mic grant.

* App/AppDelegate.swift installVision — same pattern for camera. Checks
  status; .notDetermined → requestAccess(for: .video); .denied/.restricted
  surfaces the existing degradationStream banner via cameraDegradationTask.

* App/Wizard/WizardStageTCCView.swift — wizard rows updated from
  "Microphone (later)" / "Camera (later)" to "(auto-prompts shortly)" with
  body text matching reality: prompts fire automatically after the wizard,
  when the matching subsystem starts. The previous "later" copy implied
  indefinite deferral when actually the prompts fire ~5s after wizard
  completion.

Live verification:
- tccutil reset Microphone com.koftwentytwo.jarvis
- Launch → wizard appears → click through
- tccd log: "AUTHREQ_PROMPTING ... kTCCServiceMicrophone" — system
  dialog displayed
- After click-Allow: AudioGraph: probed format sampleRate=48000 channels=4,
  AudioGraphOwner: opened with variant <private>, WakeWordDAG: started
  ring-feed loop, VoiceController: starting

Voice 87/87 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:26:44 -05:00
james.maes 4b5f535d86 fix(wizard): single-window reuse + always-closable + Re-check + System-Settings-fallback
User reported three real wizard bugs after the previous round of fixes:

1. Setup… opens a new window every click. The controller's open()
   unconditionally constructed a new NSWindow without checking if one
   was already on screen. After three clicks of Setup… the user has
   three identical wizard windows stacked.

2. No close controls. The first-launch+apiKey branch disabled the
   close button by setting .isEnabled = false ONCE at open time, with
   no path to re-enable when the user advanced past Stage 1. Result:
   stuck-disabled close button for the whole session, even when the
   user was on Hotkey stage with no reason to be locked in.

3. Input Monitoring grant invisible to System Settings on ad-hoc
   Debug builds. Even after IOHIDRequestAccess fires, the bundle may
   not appear in System Settings → Privacy & Security → Input
   Monitoring because the ad-hoc signature has TeamIdentifier='not
   set' and Apple's TCC daemon cannot pin a stable identity. Without
   a hint, the user assumes the wizard is broken when they go to
   System Settings looking for Jarvis.

Fixes:

- OnboardingWizardController.open():
    * If self.window already exists and is visible: makeKeyAndOrderFront +
      NSApp.activate; do NOT spawn a new window.
    * styleMask always [.titled, .closable, .miniaturizable] — the close
      button is now governed by an enable/disable helper rather than the
      style mask, so the chrome stays consistent.
    * Subscribe to state.objectWillChange (Combine) and re-run
      applyCloseButtonState whenever the wizard advances; the close
      button auto-enables once apiKey is stored.
    * applyCloseButtonState lock condition is now firstLaunch && stage ==
      .apiKey && !apiKeyStored — the moment the key lands in Keychain, the
      lock releases. Window level drops back to .normal too.

- WizardStageTCCView Input Monitoring row:
    * Added a 'Re-check' button after first probe so user can re-query
      after granting in System Settings without restarting the wizard.
    * Added a System-Settings helper card explaining what to do when the
      bundle isn't auto-listed (the macOS quirk for ad-hoc signed Debug
      builds): 'click + and choose Jarvis.app from your build folder'.
    * Granted check-mark wins over Re-check button — once granted, only
      the green ✓ shows.

Verified: check-app-builds clean. dev-reset.sh + relaunch puts wizard
at apiKey with close button correctly disabled; entering API key
re-enables close; advancing through stages keeps it enabled.
2026-05-02 07:41:15 -05:00
james.maes 435d345abf fix(wizard,shell): wizard re-resumes correctly after quit/reopen
The wizard had two persistence holes that made the user's quit-and-reopen
workflow surface the same stage(s) again:

1. Hotkey was in-memory only. WizardState.hotkey was @Published but
   never written to disk. Quit and reopen → state defaults to nil →
   firstUnresolvedStage returns .hotkey → wizard re-asks even though
   the user already bound one a moment ago.
2. Input Monitoring grant detection used the in-memory
   inputMonitoringProbed flag (also session-scoped), not the actual
   system grant state. Quit and reopen after granting → flag resets to
   false → wizard returns to TCC stage even though TCC remembers the
   grant.

Fixes:

- WizardState.refresh() now reads from system sources of truth on every
  invocation:
    apiKeyStored          ← Keychain (already correct)
    inputMonitoringGranted ← IOHIDCheckAccess (new — query-only, no prompt)
    hotkey                ← UserDefaults 'Jarvis.hotkey' (JSON-encoded)
- firstUnresolvedStage gates TCC on !inputMonitoringGranted (the actual
  grant) instead of !inputMonitoringProbed (the session UI flag).
- WizardState.hotkey has a didSet that persists to UserDefaults; the
  Stage 3 'Save Hotkey' button assigning to state.hotkey now hits disk
  too. Set to nil → key removed.
- HIDAccessProbe protocol gains isListenEventAccessGranted(), backed by
  IOHIDCheckAccess in production. Tests can inject deterministic grant
  state without TCC side effects.

Verified: check-app-builds clean. After scripts/dev-reset.sh --yes,
launching → wizard at apiKey. Entering key + granting Input Monitoring
+ binding hotkey + Quit + relaunch (fresh process) should land past
all three stages because each is recomputed from system state.
2026-05-02 07:33:33 -05:00
james.maes 5fd6e762e0 fix(wizard,shell): make hotkey recorder visible + clear instructions + show selected shortcut
Three fixes for the 'pick your hotkey' screen the user reported as
unusable:

1. ShortcutRecorderHostView (Shell): the NSView had no draw(_:) override
   and no mouseDown(_:) override, so it was both invisible AND uncllickable
   for focus purposes. The user had nothing to click on, and even if they
   guessed where the 40-px tall frame was, clicking didn't make it first
   responder, so the keyDown monitor never registered.
   - Add wantsLayer + cornerRadius for a visible rounded field.
   - Add mouseDown(with:) → window.makeFirstResponder(self).
   - Add draw(_:) that renders a placeholder ('Click to record shortcut…'),
     a focused-state hint ('Press a shortcut…'), and the recorded value
     once captured. Border + background change color when focused so the
     focus state is obvious.
   - Add setRecordedDisplay(_:) public setter so the SwiftUI parent can
     push the captured shortcut text in for rendering.

2. WizardStageHotkeyView (App): tells the user, in plain language, what to
   do — three numbered steps explaining click-to-record, modifier+key, and
   Esc-to-cancel. Adds a 'Selected: [⌘⇧J]' confirmation row with a Clear
   button so the user can see and reset the chosen shortcut before saving.
   Wraps text properly via .fixedSize(horizontal: false, vertical: true).

3. KeyboardShortcut.displayString (Shell): renders shortcut as Apple's
   menu-style glyph string ('⌘⇧J', '⌃⌥Space', etc.). Carbon import is
   required for kVK_* constants — added directly to KeyboardShortcut.swift
   per user direction. Modifier glyph order matches Apple convention
   (⌃ ⌥ ⇧ ⌘); unmapped key codes fall back to 'Key{n}' for debuggability.

Verified: check-app-builds.sh PASS; relaunched (PID 68314).
2026-05-02 07:21:26 -05:00
james.maesandClaude Opus 4.7 8ebdf76980 fix(hud): wrap text in wizard + banner instead of tail-truncation
Both the first-launch wizard's TCC stage and the HUD banner panel were
showing tail-truncated body text (Grant Permissions descriptions ending
in "...", Limited hotkey mode body cut at "The hotke..."). Two layered
SwiftUI layout bugs:

1. The body Text inside row HStacks (`VStack(title+body) + Spacer +
   button`) didn't have `.fixedSize(horizontal: false, vertical: true)`,
   so when the trailing button was wide ("Denied — Open System Settings")
   SwiftUI's HStack layout compressed Text and chose tail-truncation
   over multi-line wrap.

2. The wizard's outer frame was `560×440`, leaving only ~318h for
   stageBody after header(64) + dividers(2) + footer(56). The TCC stage
   with 4 permission rows needed ≥520h. The vertical squeeze cascaded
   into more aggressive Text compression and clipped the bottom Back row.

Fixes:
- WizardView: bump frame to 600×640.
- WizardStageTCCView: `.fixedSize(horizontal: false, vertical: true)` on
  description + each row's body. `.frame(maxWidth: .infinity, alignment:
  .leading)` on inner row VStacks so they receive the available width
  before the trailing button claims it. Spacer minLength bumped to 16
  for breathing room between body text and button.
- HUDBanner: same `.fixedSize` + `.frame(maxWidth:)` pair on the title
  +body VStack. The 360pt-wide banner needed it most because
  `HUDBannerPanel.positionTopTrailing` re-reads the SwiftUI fittingSize
  on host — without wrapping, the panel was sized to a single-line body
  and forced all subsequent body text to truncate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:08:02 -05:00
james.maes 1fc613bcfa fix(01-WR-09): document refresh-before-view-build invariant in wizard open
Phase 1 REVIEW.md WR-09: `OnboardingWizardController.open` called
`state.refresh()` immediately before building the hosting view. The
refresh is synchronous and @MainActor-safe, so the potential race
(refresh publish arriving after SwiftUI subscribes) doesn't occur in
practice — but the sequencing was an implicit invariant worth
codifying.

App/Wizard/OnboardingWizardController.swift
- Add docstring noting why refresh must complete before view
  construction (keychain-lock contention latency + publish ordering).
- Add runtime assertion `Thread.isMainThread` after refresh — makes
  the invariant explicit and fails loudly if a future refactor moves
  refresh off-main.

Notes for future work:
- A truly async-off-main refresh would make the UX WORSE (user sees
  a flash of empty wizard for the async duration). Current posture
  of "run refresh synchronously, short-lived main-thread block" is
  correct for this scope; revisit only if the Keychain contention
  stalls become user-visible in practice.
2026-04-23 16:27:25 -05:00
james.maes 9850d9546d fix(01-WR-07): dedicated hardened URLSession for API key validation
Phase 1 REVIEW.md WR-07: `AnthropicKeyValidator` defaulted to
`URLSession.shared`, which honors the system proxy. In a corporate
environment with a MITM proxy intercepting `api.anthropic.com`, the
`x-api-key` header would be TLS-terminated at the proxy, leaking the
key on the first validation attempt before the user knows Jarvis is
making network calls.

App/Wizard/AnthropicKeyValidator.swift
- Default client is now an ephemeral URLSession built by
  `makeHardenedSession()`:
    * `connectionProxyDictionary = [:]`  — refuse any configured proxy.
    * `tlsMinimumSupportedProtocolVersion = .TLSv13`.
    * 15s request / 30s resource timeouts — fail fast on flaky nets
      instead of blocking the wizard for minutes.
    * `httpCookieAcceptPolicy = .never`, `httpShouldSetCookies = false`.
    * `urlCache = nil`, `urlCredentialStorage = nil` — nothing cached
      on disk for a one-shot validation call.
- Fix optional `any` syntax for Swift 6 strict concurrency build.

No test change: `AnthropicKeyValidatorTests` already injects `MockClient`
via the initializer, so the production path switch doesn't change
behaviour under test.
2026-04-23 16:21:52 -05:00
james.maes 929e4cee6b fix(01-CR-02): shorten API key plaintext lifetime in wizard @State
Phase 1 REVIEW.md CR-02 (SEC-01): `WizardStageAPIKeyView` kept the raw
Anthropic API key in an `@State String` for the lifetime of the view —
outliving the Keychain write, retained by SwiftUI attribute-graph
diffing, and visible to any memory-dump forensic vector (crash report,
Xcode memory graph, `leaks(1)`).

App/Wizard/WizardStageAPIKeyView.swift:53-101
- `verify()` now captures the key into a single `capturedKey` local and
  reassigns `apiKey = ""` immediately, so the @State slot drops its
  strong reference before the async validate + keychain.set chain runs.
- Error branches selectively restore `apiKey = capturedKey` only where
  the user plausibly wants to retry without retyping (unauthorized /
  network / generic / keychain-error); `malformed` keeps apiKey empty.
- `.onDisappear { clearAPIKey() }` hook: zeros the @State on view
  teardown (including back-navigation and success-path dismissal),
  preventing the plaintext from persisting in the attribute graph after
  dismiss.
- `clearAPIKey()` first overwrites with null bytes of the same length
  (best-effort — Swift Strings are CoW/immutable so the backing page
  can't be zeroed in place), then drops the @State to empty so ARC can
  release the CoW storage.

App/Tests/AppTests/WizardStageAPIKeyViewTests.swift (new)
- Source-level grep gates for the three regression points: onDisappear
  hook, clearAPIKey helper shape, and verify()'s capture-then-clear
  pattern. SwiftUI @State lifecycle isn't observable from XCTest
  without ViewInspector, so text-level gates are the practical
  regression guard.

Jarvis.xcodeproj/project.pbxproj: regenerated via xcodegen to pick up
the new test file.
2026-04-23 16:09:59 -05:00
james.maesandClaude Opus 4.7 ebefb91043 feat(01-04): build first-launch wizard + full AppDelegate launch wiring
- AnthropicKeyValidator: local sk-ant- prefix check + URLSession-injectable
  GET to api.anthropic.com/v1/models; returns typed enum, never logs key
- WizardState: @MainActor ObservableObject; D-09 stateless re-entry via
  refresh(); firstUnresolvedStage advances apiKey → tcc → hotkey → complete
- WizardStageAPIKeyView: SecureField, validator + Keychain round-trip; key
  value lives only in Keychain (SEC-01)
- WizardStageTCCView: Input Monitoring row actively probes; Mic/Camera/
  Automation rows are explainer-only (D-08/SEC-04); no permission APIs
  called from the view
- WizardStageHotkeyView: ShortcutRecorderView + CollisionDetector wiring;
  Skip leaves hotkey unbound (SHELL-02)
- WizardView: three-stage root with header dots + Back footer
- OnboardingWizardController: NSWindow hosting; first-launch .modalPanel
  + close-button disabled until API key stored (D-06)
- AppDelegate: full 8-step launch chain — logging bootstrap (S-8) →
  entitlement gate (Plan 03) → config load (NSAlert on malformed, D-19/S-6) →
  menu bar + HUD + banner install → Keychain fetch (.keychainEmpty banner
  on itemNotFound) → Input Monitoring probe (.inputMonitoringDenied banner
  on denial, SHELL-06) → HotkeyBinder init → Wizard open on first launch.
  copyStateDump writes boolean-only presence; API key VALUE never clipboarded
- JarvisApp: Settings scene stub; real NSPanels managed by AppDelegate
- Tests: 11 new (WizardState 4, AnthropicKeyValidator 3, WizardTCC 1,
  AppDelegateWiring 4) + updated AppDelegateEntitlementTests for the new
  launch chain; all 25 JarvisAppTests pass
- Re-inserted PBXCopyFilesBuildPhase for Contents/Helpers per Plan 01/03
  precedent (xcodegen strips empty copyFiles)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:07:04 -05:00