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.
117 lines
4.8 KiB
Swift
117 lines
4.8 KiB
Swift
import AppKit
|
|
import Combine
|
|
import SwiftUI
|
|
import Keychain
|
|
import Shell
|
|
|
|
/// Hosts the `WizardView` in an NSWindow and manages first-launch vs re-entry
|
|
/// modality per D-06 / D-09:
|
|
///
|
|
/// - First launch + apiKey stage → close button disabled until Stage 1
|
|
/// completes, so the user can't dismiss without a valid API key. Once
|
|
/// the user advances to TCC stage, the close button auto-enables.
|
|
/// - Re-entry via Setup… menu item → standard titled window, closable,
|
|
/// non-modal; the user can close at any time.
|
|
///
|
|
/// Window reuse: clicking Setup… repeatedly does NOT spawn additional
|
|
/// windows. If a wizard window is already open, it's brought forward and
|
|
/// state.refresh() is called so the visible stage matches truth.
|
|
///
|
|
/// D-09 "stateless re-entry": `open(...)` calls `state.refresh()` each time
|
|
/// so the wizard lands at the first unresolved stage regardless of where it
|
|
/// was when last closed.
|
|
@MainActor
|
|
public final class OnboardingWizardController {
|
|
public let state: WizardState
|
|
private var window: NSWindow?
|
|
private var stageObserver: AnyCancellable?
|
|
|
|
public init(state: WizardState) { self.state = state }
|
|
|
|
public func open(
|
|
keychain: any KeychainStore,
|
|
validator: AnthropicKeyValidator,
|
|
onGrantInputMonitoring: @escaping () -> Bool,
|
|
onComplete: @escaping () -> Void,
|
|
firstLaunch: Bool
|
|
) {
|
|
// WR-09: `state.refresh()` calls `keychain.get(.anthropic)`, which
|
|
// can block the main thread for hundreds of ms under keychain-lock
|
|
// contention or first-run ACL prompts. Refresh BEFORE we touch any
|
|
// window so the published `currentStage` is correct at view-creation
|
|
// time and no flash-of-wrong-stage occurs on re-entry.
|
|
state.refresh()
|
|
assert(Thread.isMainThread, "WR-09: state.refresh must complete on main before view creation")
|
|
|
|
// Reuse an existing window instead of stacking new ones. Each call
|
|
// to open() (e.g. menu-bar Setup… clicked repeatedly) just brings
|
|
// the same window forward.
|
|
if let existing = window, existing.isVisible {
|
|
existing.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
applyCloseButtonState(window: existing, firstLaunch: firstLaunch)
|
|
return
|
|
}
|
|
|
|
let rootView = WizardView(
|
|
state: state,
|
|
keychain: keychain,
|
|
validator: validator,
|
|
onGrantInputMonitoring: onGrantInputMonitoring,
|
|
onComplete: { [weak self] in
|
|
onComplete()
|
|
self?.close()
|
|
}
|
|
)
|
|
let hosting = NSHostingController(rootView: rootView)
|
|
let window = NSWindow(contentViewController: hosting)
|
|
// Always include .closable + .miniaturizable in the style mask so
|
|
// the user has standard window controls. The close *button* is
|
|
// separately enabled/disabled based on stage (see helper below).
|
|
window.styleMask = [.titled, .closable, .miniaturizable]
|
|
window.title = "Setup — Jarvis"
|
|
window.isReleasedWhenClosed = false
|
|
|
|
// Track the existing window before configuring stage-dependent state
|
|
// so the helper can read it back.
|
|
self.window = window
|
|
|
|
applyCloseButtonState(window: window, firstLaunch: firstLaunch)
|
|
|
|
// Re-enable the close button when the user advances past .apiKey on
|
|
// first launch — without this the close button stays stuck disabled
|
|
// for the entire session. objectWillChange fires before the
|
|
// published value updates; defer to next runloop tick to read the
|
|
// settled state.
|
|
stageObserver = state.objectWillChange
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] in
|
|
guard let self, let w = self.window else { return }
|
|
self.applyCloseButtonState(window: w, firstLaunch: firstLaunch)
|
|
}
|
|
|
|
window.center()
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
public func close() {
|
|
window?.close()
|
|
window = nil
|
|
stageObserver = nil
|
|
}
|
|
|
|
/// Enable the standard close button unless we're on first-launch + the
|
|
/// user hasn't entered an API key yet (Stage 1 of the very first run).
|
|
/// Once the user advances past .apiKey, the button re-enables.
|
|
private func applyCloseButtonState(window: NSWindow, firstLaunch: Bool) {
|
|
let lockClosed = firstLaunch
|
|
&& state.currentStage == .apiKey
|
|
&& !state.apiKeyStored
|
|
window.standardWindowButton(.closeButton)?.isEnabled = !lockClosed
|
|
// Elevated panel level only while locked; otherwise let the wizard
|
|
// behave like a normal window so it can be sent behind other apps.
|
|
window.level = lockClosed ? .modalPanel : .normal
|
|
}
|
|
}
|