diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index 471690c..9028411 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -2706,10 +2706,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } - // Navigation delegate: fires handshake on first didFinish. - let navDelegate = BridgeNavigationDelegate { [weak self] in - self?.webviewBridge?.startHandshake() - } + // Navigation delegate: fires handshake on first didFinish; surfaces + // load failures (audit-2026-05-12 S1 / #40) via the bus + a critical + // banner so a missing/stale bundle is never a silent black HUD. + let navDelegate = BridgeNavigationDelegate( + onDidFinish: { [weak self] in + self?.webviewBridge?.startHandshake() + }, + onDidFail: { [weak self] error in + self?.handleWebviewLoadFailure(error) + } + ) bridgeNavigationDelegate = navDelegate panel.webView.navigationDelegate = navDelegate @@ -2726,11 +2733,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // closure bridges App.HudState → Bus.HudState (rawValue round-trip) // and dispatches to the webview bridge on the MainActor. `[weak // bridge]` avoids a retain cycle with `self` via the bridge. - let coordinator = HudStateCoordinator(emit: { [weak bridge] appState in - guard let bridge else { return } - Task { @MainActor in - try? await bridge.send(.hudState(busHudState(from: appState))) + // + // Audit-2026-05-12 S1 / #41 — the emit closure ALSO fans state to + // `MenuBarIconController.transition(to:)` so the menu-bar icon + // animates per agent state (breath/rotate/shimmer/glow). Before + // this fan-out, `transition(to:)` was production-dead: the method + // existed and was unit-tested, but no production call site invoked + // it, so the icon stayed in `.idle` for the whole app lifetime + // regardless of agent activity. This is NOT a HUD-08 single-writer + // violation — `transition(to:)` writes the icon's `currentState`, + // not `BusOutbound.hudState`, which has only one constructor + // (`bridge.send(.hudState(...))` below). + let coordinator = HudStateCoordinator(emit: { [weak bridge, weak self] appState in + if let bridge { + Task { @MainActor in + try? await bridge.send(.hudState(busHudState(from: appState))) + } } + self?.menuBarController?.transition(to: appState) }) // Dormant producer streams. Phase 4 replaces `agentStream` with the @@ -2816,6 +2836,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate { panel.webView.loadFileURL(entryURL, allowingReadAccessTo: resourcesDir) } + /// Audit-2026-05-12 S1 / #40 — webview navigation failure handler. + /// Routes `didFailProvisionalNavigation` and `didFail` from + /// `BridgeNavigationDelegate` into: + /// + /// 1. `WebviewBridge.failHandshake(reason:)` — transitions the + /// handshake state machine to `.loadFailed` and fires the same + /// `alertPresenter` (TCCAlertService.presentHardBlock) the + /// `.timedOut` and `.mismatched` paths already use. + /// 2. A critical-severity banner via `HUDBannerCoordinator` so the + /// user sees a HUD-native error surface (matching the pattern + /// `.bootHealthCritical` uses for memory-off / no-API-key / + /// no-MCP-tools / webview-never-armed failures). + /// + /// Both surfaces fire — the alert is the hard-block path that already + /// terminates the app on handshake-mismatch / timeout; the banner is + /// a defense-in-depth render path that survives even if the alert is + /// suppressed (e.g., AppDelegate test seam with a silent + /// `alertPresenter`). + @MainActor + func handleWebviewLoadFailure(_ error: Error) { + let nsError = error as NSError + let reason = "\(nsError.domain) \(nsError.code): \(nsError.localizedDescription)" + systemLogger?.critical("webview navigation failed — surfacing as load-failed: \(reason)") + webviewBridge?.failHandshake(reason: reason) + bannerCoordinator?.enqueue(.bootHealthCritical( + subsystem: "webview", + reason: "HUD bundle failed to load (\(reason))" + )) + } + // MARK: - Wizard /// `BannerSink` adapter so `InputMonitoringProbe` (Shell) can enqueue onto @@ -2984,14 +3034,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// the main actor via `MainActor.assumeIsolated` — the same Swift 6 pattern /// `WebviewBridge` uses for `WKScriptMessageHandlerWithReply`. @MainActor -private final class BridgeNavigationDelegate: NSObject, WKNavigationDelegate { +final class BridgeNavigationDelegate: NSObject, WKNavigationDelegate { let onDidFinish: @MainActor () -> Void - init(onDidFinish: @escaping @MainActor () -> Void) { + /// Audit-2026-05-12 S1 / #40 — invoked on either + /// `didFailProvisionalNavigation` or `didFail`. Without this hook the + /// 2s handshake timeout never arms (it's scheduled inside + /// `startHandshake`, which only runs on `didFinish`), so a bundle-404 + /// or JS-parse-error before mount left the HUD a silent black square. + let onDidFail: @MainActor (Error) -> Void + init( + onDidFinish: @escaping @MainActor () -> Void, + onDidFail: @escaping @MainActor (Error) -> Void + ) { self.onDidFinish = onDidFinish + self.onDidFail = onDidFail } nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { MainActor.assumeIsolated { onDidFinish() } } + /// Provisional navigation = "the server / file URL didn't even start + /// loading" (404 on `file://`, missing index.html, DNS / sandbox + /// rejection). The WKError comes via `error`. + nonisolated func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + MainActor.assumeIsolated { onDidFail(error) } + } + /// Post-provisional navigation failure — the page started loading and + /// then errored (subresource 404 mid-stream, decode error after a 200). + nonisolated func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error + ) { + MainActor.assumeIsolated { onDidFail(error) } + } } // MARK: - Voice subsystem adapters (Plan 06-05) diff --git a/App/Boot/BootHealthProbes.swift b/App/Boot/BootHealthProbes.swift index bb32a38..3b5b44e 100644 --- a/App/Boot/BootHealthProbes.swift +++ b/App/Boot/BootHealthProbes.swift @@ -492,6 +492,14 @@ public struct WebviewBootHealthProbe: BootHealthProbe { status: .failed(reason: "JS never acked hello — webview bundle missing or crashed at startup", severity: .critical), evidence: "HandshakeState.timedOut after 2s" ) + case .loadFailed(let reason): + // Audit-2026-05-12 S1 / #40 — bundle navigation failed before + // `didFinish` could fire. Same severity as `.timedOut`: the + // user sees no HUD until the bundle is rebuilt. + return ProbeOutcome( + status: .failed(reason: "webview bundle load failed: \(reason); rebuild webview bundle", severity: .critical), + evidence: "HandshakeState.loadFailed(\(reason))" + ) } } } diff --git a/App/Tests/AppTests/BridgeNavigationFailureTests.swift b/App/Tests/AppTests/BridgeNavigationFailureTests.swift new file mode 100644 index 0000000..7c22ef9 --- /dev/null +++ b/App/Tests/AppTests/BridgeNavigationFailureTests.swift @@ -0,0 +1,169 @@ +import XCTest +import AppKit +import WebKit +@testable import Jarvis +@testable import Bus +@testable import Config +@testable import Keychain +@testable import Shell + +/// Audit-2026-05-12 S1 / #40 regression tests: +/// +/// `BridgeNavigationDelegate.didFail*` arms surface a HUD-native critical +/// banner + transition the handshake state machine to `.loadFailed` so a +/// missing/stale bundle is never a silent black HUD. Before this fix, the +/// delegate only implemented `didFinish` — a 404 on the JS bundle (or any +/// pre-mount JS error) left the handshake stuck in `.idle` forever and the +/// 2s `HandshakeTiming.timeout` was never armed. +@MainActor +final class BridgeNavigationFailureTests: XCTestCase { + // MARK: - Fakes (mirror existing AppDelegateBusWiringTests pattern) + + private struct FakeKeychain: KeychainStore { + let apiKeyStored: Bool + func set(_ value: String, for item: KeychainItem) throws {} + func get(_ item: KeychainItem) throws -> String { + guard apiKeyStored else { throw KeychainError.itemNotFound } + return "sk-ant-fake" + } + func delete(_ item: KeychainItem) throws {} + } + + private struct FakeHIDProbe: HIDAccessProbe { + let granted: Bool + func requestListenEventAccess() -> Bool { granted } + } + + private struct EntitlementYes: EntitlementGateProbe { func isVerified() -> Bool { true } } + + private func makeSnapshots() -> (LaunchSnapshot, PerTurnSnapshot) { + let launchJSON = """ + {"schemaVersion":1,"ollama":{"baseURL":"http://127.0.0.1:11434"},"applescript":{"confirmationRequired":true},"toolBlocklist":[],"confirmationPolicy":{"timeoutSeconds":60},"logging":{"fileLevel":"info","osLogLevel":"info"}} + """.data(using: .utf8)! + let perTurnJSON = """ + {"schemaVersion":1,"provider":"anthropic","tts":{"tier":"tier1"},"stt":{"whisperKitFallback":false},"featureFlags":{}} + """.data(using: .utf8)! + return ( + try! JSONDecoder().decode(LaunchSnapshot.self, from: launchJSON), + try! JSONDecoder().decode(PerTurnSnapshot.self, from: perTurnJSON) + ) + } + + private func cleanUp(_ delegate: AppDelegate) { + if let item = delegate.statusItem { + NSStatusBar.system.removeStatusItem(item) + } + delegate.wizardController?.close() + } + + private func makeDelegate(apiKey: Bool = true) -> AppDelegate { + let delegate = AppDelegate() + delegate.entitlementProbe = EntitlementYes() + delegate.configLoader = { _ in self.makeSnapshots() } + delegate.configWriter = { _ in self.makeSnapshots() } + delegate.keychainStore = FakeKeychain(apiKeyStored: apiKey) + delegate.hidProbe = FakeHIDProbe(granted: true) + delegate.loggingBootstrap = {} + return delegate + } + + // MARK: - Tests + + /// `handleWebviewLoadFailure` transitions the bridge to `.loadFailed` and + /// enqueues a critical banner. Before this fix, a stale bundle reference + /// (e.g. `