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

148 lines
7.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Shell
/// Stage 2 of the first-launch wizard (UI-SPEC Surface 1, D-08/SEC-04).
///
/// ONLY the Input Monitoring row invokes a real TCC prompt. The Microphone,
/// Camera, and Automation rows are explainer-only — they surface "coming
/// later" copy and never call any permission API. That's the D-08 contract:
/// Phase 1 prompts ONLY what Phase 1 needs. The test
/// `WizardTCCStageTests.test_onlyInputMonitoringTriggers` pins this shape.
///
/// The actual HID probe call happens inside `InputMonitoringProbe`, not here
/// — AppDelegate injects a closure that wraps the probe (and its banner sink)
/// so this view has no dependency on the probe or sink types.
struct WizardStageTCCView: View {
@ObservedObject var state: WizardState
let onAdvance: () -> Void
/// Closure wraps `InputMonitoringProbe.check(sink:)`. Injected by WizardView.
let onGrantInputMonitoring: () -> Bool
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Grant Permissions").font(.system(size: 17, weight: .semibold))
// `.fixedSize(horizontal: false, vertical: true)` opts into multi-
// line wrap instead of tail-truncation when the proposed width is
// narrower than the single-line natural width. Same idiom is
// applied below to each row's body text.
Text("Jarvis needs a few system permissions to do its job. Only one is needed right now; the others will ask when the matching feature is added.")
.foregroundColor(Color(NSColor.secondaryLabelColor))
.fixedSize(horizontal: false, vertical: true)
inputMonitoringRow
explainerRow(
title: "Microphone (auto-prompts shortly)",
body: "Jarvis will request mic access automatically a moment after this wizard, when the voice loop starts. Wake-word + voice replies depend on it.",
ctaLabel: "Learn more →"
)
explainerRow(
title: "Camera (auto-prompts shortly)",
body: "Jarvis will request camera access automatically a moment after this wizard, when vision features start. Used for presence detection and the camera button.",
ctaLabel: "Learn more →"
)
explainerRow(
title: "Automation (later)",
body: "Will be requested per-app, the first time Jarvis is asked to automate that app. You'll approve each app individually.",
ctaLabel: "Learn more →"
)
Spacer()
HStack {
Button("Skip — I'll grant this later") { onAdvance() }
Spacer()
Button("Continue") { onAdvance() }.buttonStyle(.borderedProminent)
}
}
.padding(.horizontal, 48)
.padding(.vertical, 24)
}
private var inputMonitoringRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Input Monitoring (needed now)").font(.headline)
Text("Lets Jarvis see a global hotkey press even when another app is focused. Without this, Jarvis only responds when its window is frontmost.")
.foregroundColor(Color(NSColor.secondaryLabelColor))
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 16)
if state.inputMonitoringGranted {
Label("Granted", systemImage: "checkmark.circle.fill")
.foregroundColor(Color(NSColor.systemGreen))
} else if !state.inputMonitoringProbed {
Button("Grant Access") {
state.inputMonitoringGranted = onGrantInputMonitoring()
state.inputMonitoringProbed = true
}
} else {
Button("Re-check") {
// Re-fire the probe. If the user granted in System
// Settings between sessions or in another window,
// this picks up the new grant; if still denied, the
// helper text below points them at System Settings.
state.inputMonitoringGranted = onGrantInputMonitoring()
}
}
}
// Only show the System Settings helper after the user has tried
// the in-app prompt and it didn't grant — covers both "user
// clicked Deny" and the macOS-quirk case where ad-hoc signed
// Debug builds don't auto-list in System Settings until the
// first IOHIDRequestAccess call has been made.
if state.inputMonitoringProbed && !state.inputMonitoringGranted {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "info.circle")
.foregroundColor(Color(NSColor.secondaryLabelColor))
Text("Not granted. Open System Settings → Privacy & Security → Input Monitoring, find ‘Jarvis’ in the list, and turn it on. Then click Re-check above.")
.foregroundColor(Color(NSColor.secondaryLabelColor))
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
Button("Open System Settings") {
if let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") {
NSWorkspace.shared.open(url)
}
}
Text("If Jarvis is missing from the list, click + and choose Jarvis.app from your build folder.")
.font(.system(size: 11))
.foregroundColor(Color(NSColor.tertiaryLabelColor))
.fixedSize(horizontal: false, vertical: true)
}
}
.font(.system(size: 12))
.padding(10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(NSColor.windowBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color(NSColor.separatorColor), lineWidth: 1)
)
}
}
}
/// Explainer-only rows per D-08. No TCC prompt, no permission-framework
/// call — the label is a static link; docs page lands post-P1, button
/// is a no-op in P1. The sibling test pins this.
private func explainerRow(title: String, body: String, ctaLabel: String) -> some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(body)
.foregroundColor(Color(NSColor.secondaryLabelColor))
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 16)
Text(ctaLabel).foregroundColor(Color(NSColor.linkColor))
}
}
}