diff --git a/native/macos/Sources/LocalBolt/BoltBridge.swift b/native/macos/Sources/LocalBolt/BoltBridge.swift index 59fa428..a37d5ff 100644 --- a/native/macos/Sources/LocalBolt/BoltBridge.swift +++ b/native/macos/Sources/LocalBolt/BoltBridge.swift @@ -41,6 +41,23 @@ enum BoltBridge { bolt_free_string(ptr) return path } + + /// Latest WebTransport lifecycle in a daemon stderr tail. + /// `active` is nil when the tail has no lifecycle line. + static func parseWtSessionTail(_ stderr: String) -> (active: Bool?, sas: String?) { + stderr.withCString { ptr in + guard let out = bolt_parse_wt_session_stderr(ptr) else { + return (nil, nil) + } + defer { bolt_free_string(out) } + guard let data = String(cString: out).data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return (nil, nil) } + let active = obj["active"] as? Bool + let sas = (obj["sas"] as? String).flatMap { $0.count == 6 ? $0 : nil } + return (active, sas) + } + } } // ── Daemon Lifecycle ──────────────────────────────────────── @@ -74,8 +91,13 @@ final class DaemonManager { guard let wtUrl, let wtCertHash else { return false } return !wtUrl.isEmpty && !wtCertHash.isEmpty } - /// True when daemon has an active WT session (detected from stderr). - /// Latches on "[WT_SESSION]...ACTIVE_SESSION registered", clears on "cleared". + /// True when daemon stderr shows a live session the shell must surface. + /// + /// WebTransport answerers do not emit `session.connected`. bolt-daemon prints + /// `[WT_SESSION] … session established` and `[SESSION] … active session handle registered`, + /// then `[SESSION] … active session handle cleared`. It does not print + /// `ACTIVE_SESSION registered`. The latch stays up after that line scrolls + /// out of the tail, and drops only on a later clear. private(set) var wtSessionActive: Bool = false /// SAS code extracted from daemon stderr "[SAS] XXXXXX" for WT sessions. private(set) var wtSessionSAS: String? = nil @@ -141,6 +163,34 @@ final class DaemonManager { clearAdvertisedTransport() } + /// Apply one stderr tail. `active == nil` means the tail has no lifecycle line. + /// + /// SAS is published before the active flag so a view that observes the flag + /// already sees the code. A clear only drops a latch that was up; a tail + /// with no token leaves the latch alone. + private func applyWtSessionTail(_ tail: String) { + let parsed = BoltBridge.parseWtSessionTail(tail) + switch parsed.active { + case .some(true): + if let sas = parsed.sas, wtSessionSAS != sas { + wtSessionSAS = sas + print("[DAEMON-POLL] WT SAS extracted: \(sas)") + } + if !wtSessionActive { + wtSessionActive = true + print("[DAEMON-POLL] WT session detected from stderr") + } + case .some(false): + if wtSessionActive || wtSessionSAS != nil { + wtSessionActive = false + wtSessionSAS = nil + print("[DAEMON-POLL] WT session ended (stderr)") + } + case .none: + break + } + } + /// Copies of the values the startup poll needs. Taken on the main thread so the /// wait does not read DaemonManager concurrently with its stderr timer. func wtWaitSnapshot() -> (dataDir: String, wsPort: UInt16, baseline: String?) { @@ -266,50 +316,15 @@ final class DaemonManager { isRunning = running } - if let ptr = bolt_daemon_recent_stderr(h, 30) { - recentStderr = String(cString: ptr) + // The bridge keeps up to 1000 stderr lines. Read the whole ring so a + // burst of handshake or transfer logs cannot push the session line out + // before this poll. Diagnostics still show only the tail. + if let ptr = bolt_daemon_recent_stderr(h, 1000) { + let tail = String(cString: ptr) bolt_free_string(ptr) - } - - // Detect WT session lifecycle from daemon stderr. - // The bridge exposes a rolling tail, so parse in order and honor only - // the latest lifecycle token. Otherwise stale "cleared" or "[SAS]" - // lines from a previous session can poison reconnect state. - var latestWtLifecycle: Bool? - var pendingSas: String? - var latestRegisteredSas: String? - for line in recentStderr.split(separator: "\n") { - if let range = line.range(of: "[SAS] ") { - let candidate = String(line[range.upperBound...]).prefix(6) - if candidate.count == 6 { - pendingSas = String(candidate) - } - } - if line.contains("ACTIVE_SESSION registered") { - latestWtLifecycle = true - latestRegisteredSas = pendingSas - } else if line.contains("ACTIVE_SESSION cleared") { - latestWtLifecycle = false - latestRegisteredSas = nil - } - } - - if let latestWtLifecycle { - if latestWtLifecycle { - if !wtSessionActive { - wtSessionActive = true - wtSessionSAS = nil - print("[DAEMON-POLL] WT session detected from stderr") - } - if let sas = latestRegisteredSas, wtSessionSAS != sas { - wtSessionSAS = sas - print("[DAEMON-POLL] WT SAS extracted: \(sas)") - } - } else if wtSessionActive { - wtSessionActive = false - wtSessionSAS = nil - print("[DAEMON-POLL] WT session ended (stderr)") - } + applyWtSessionTail(tail) + let lines = tail.split(separator: "\n", omittingEmptySubsequences: false) + recentStderr = lines.suffix(40).joined(separator: "\n") } // Auto-cleanup if daemon died diff --git a/native/macos/Sources/LocalBolt/LocalBoltApp.swift b/native/macos/Sources/LocalBolt/LocalBoltApp.swift index 625950c..6e24cf2 100644 --- a/native/macos/Sources/LocalBolt/LocalBoltApp.swift +++ b/native/macos/Sources/LocalBolt/LocalBoltApp.swift @@ -268,18 +268,17 @@ struct ContentView: View { transferStartTime = nil } } - // WT session detection: daemon stderr indicates session up, but IPC - // events are not emitted for WebTransport sessions. Advance the - // state machine here so the UI reflects the connected session. + // WebTransport answerers do not emit daemon://session-connected. The + // shell latches daemon stderr instead. `task(id:)` also runs when the + // latch was already set before this view subscribed (session up while + // the pairing sheet was on screen). + .task(id: daemon.wtSessionActive) { + promoteLiveWtSession() + } .onChange(of: daemon.wtSessionActive) { - if daemon.wtSessionActive && ipc.sessionPhase == .connecting { - if ipc.markConnected() { - ipc.adoptConnectedPeer(identityKey: nil) - applyWtVerificationCode(daemon.wtSessionSAS) - print("[WT-DETECT] Advanced to .connected from daemon stderr") - } - } - if !daemon.wtSessionActive && ipc.sessionPhase == .connected { + if daemon.wtSessionActive { + promoteLiveWtSession() + } else if ipc.sessionPhase == .connected { ipc.disconnectSession(reason: "WT session ended") print("[WT-DETECT] WT session ended — disconnected") } @@ -290,8 +289,37 @@ struct ContentView: View { } } + /// Leave Establishing once the daemon's WebTransport session is up. + /// + /// Acceptor flow is already `.connecting` after the signaling Accept. + /// The initiator can still be `.requesting` if `connection_accepted` has + /// not been applied yet; the daemon session is the source of truth then. + /// `.incomingRequest` is left alone so a handshake cannot skip Decline. + private func promoteLiveWtSession() { + guard daemon.wtSessionActive else { return } + if ipc.sessionPhase == .requesting { + _ = ipc.beginConnecting() + } + guard ipc.sessionPhase == .connecting, ipc.markConnected() else { return } + ipc.adoptConnectedPeer(identityKey: nil) + applyWtVerificationCode(daemon.wtSessionSAS) + print("[WT-DETECT] Advanced to .connected from daemon stderr") + } + private func applyWtVerificationCode(_ sas: String?) { guard let sas, ipc.sessionPhase == .connected else { return } + // A second observer (task + onChange, or a later tail) must not bounce + // an in-session approval or a mismatch alert back to unverified. + if let trust = ipc.connectedPeer?.trust { + switch trust { + case .verified, .mismatch(_, _): + return + case .unverified(let current) where current == sas: + return + case .unverified(_), .legacy: + break + } + } // Item-6 / pre-EA1: no reconnect auto-trust — always surface the SAS as // unverified for user review; a stored pin never skips it. ipc.connectedPeer?.trust = .unverified(sas: sas) diff --git a/native/shared/include/bolt_native_bridge.h b/native/shared/include/bolt_native_bridge.h index 465484e..6e04878 100644 --- a/native/shared/include/bolt_native_bridge.h +++ b/native/shared/include/bolt_native_bridge.h @@ -105,6 +105,18 @@ uint32_t bolt_daemon_pid(BoltDaemon* handle); /// Caller must free with bolt_free_string. char* bolt_daemon_recent_stderr(BoltDaemon* handle, uint32_t last_n); +/// Parse a bolt-daemon stderr tail for the WebTransport session latch. +/// +/// Browser peers do not produce `session.connected` on IPC. The shell must +/// read stderr instead. Returned JSON is +/// `{"active":true|false|null,"sas":"XXXXXX"|null}`. `active` is null when +/// this tail has no lifecycle line (the caller keeps its latch). A live +/// session is `[WT_SESSION] … session established` or +/// `[SESSION] … active session handle registered`, not the string +/// `ACTIVE_SESSION registered`. Caller frees a non-null return with +/// bolt_free_string. +char* bolt_parse_wt_session_stderr(const char* stderr_tail); + /// Get the daemon's IPC socket path. Caller must free with bolt_free_string. char* bolt_daemon_socket_path(BoltDaemon* handle); diff --git a/native/shared/src/lib.rs b/native/shared/src/lib.rs index 5828a33..1ad3010 100644 --- a/native/shared/src/lib.rs +++ b/native/shared/src/lib.rs @@ -30,6 +30,7 @@ mod advertise_policy; mod daemon; mod ipc; mod signaling; +mod wt_session_log; pub use daemon::*; pub use ipc::*; pub use signaling::*; diff --git a/native/shared/src/wt_session_log.rs b/native/shared/src/wt_session_log.rs new file mode 100644 index 0000000..19790b3 --- /dev/null +++ b/native/shared/src/wt_session_log.rs @@ -0,0 +1,306 @@ +//! WebTransport session lifecycle as printed by bolt-daemon stderr. +//! +//! The browser path (Studio Chrome → Mac app) is the WebTransport answerer in +//! `wt_endpoint.rs`. Unlike the WebSocket answerer, that path does **not** emit +//! `session.connected` / `session.sas` on IPC. The Mac shell therefore watches +//! stderr. It used to look for the Rust static's name, `ACTIVE_SESSION registered`. +//! The daemon never prints that. It prints: +//! +//! ```text +//! [SAS] 5BE04E +//! [WT_SESSION] session established, entering shared session loop +//! [SESSION] active session handle registered +//! ``` +//! +//! and, when the shared loop exits: +//! +//! ```text +//! [SESSION] active session handle cleared +//! ``` +//! +//! `[WT_HELLO] … HELLO ok` is only the handshake. The UI must stay on +//! Establishing until one of the session lines above appears. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; + +use serde_json::json; + +/// Latest session lifecycle in one stderr tail. +/// +/// `active` is `None` when this tail has no lifecycle line, so the caller can +/// keep a latched value after the line scrolls out of the window. `sas` is the +/// code paired with the latest establish line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WtSessionTail { + pub active: Option, + pub sas: Option, +} + +impl WtSessionTail { + fn json(&self) -> String { + json!({ + "active": self.active, + "sas": self.sas, + }) + .to_string() + } +} + +/// Parse a daemon stderr tail. Lines are visited in order; the latest lifecycle +/// line wins, so a stale `cleared` above a new session does not stick. +pub fn parse_wt_session_stderr(stderr: &str) -> WtSessionTail { + let mut active: Option = None; + let mut pending_sas: Option = None; + let mut established_sas: Option = None; + + for line in stderr.lines() { + if let Some(code) = sas_code(line) { + pending_sas = Some(code.clone()); + // The code is normally printed just before the establish line. If a + // tail has them in the other order, keep the code anyway. + if active == Some(true) && established_sas.is_none() { + established_sas = Some(code); + } + } + if is_clear(line) { + active = Some(false); + pending_sas = None; + established_sas = None; + } else if is_establish(line) { + active = Some(true); + established_sas = pending_sas.clone(); + } + } + + WtSessionTail { + active, + sas: if active == Some(true) { + established_sas + } else { + None + }, + } +} + +fn is_establish(line: &str) -> bool { + // Shared loop, every transport, including WebTransport. This is the line + // the old shell meant by "ACTIVE_SESSION registered". + line.contains("active session handle registered") + || line.contains("ACTIVE_SESSION registered") + // WebTransport answerer, logged immediately before the shared loop. + // WS/QUIC say "session established" under a different tag and also emit + // IPC; do not treat those as this latch by themselves. + || (line.contains("[WT_SESSION]") && line.contains("session established")) +} + +fn is_clear(line: &str) -> bool { + line.contains("active session handle cleared") || line.contains("ACTIVE_SESSION cleared") +} + +/// `[SAS] XXXXXX` — bolt SAS codes are 6 alphanumeric characters. +fn sas_code(line: &str) -> Option { + let rest = line.split_once("[SAS] ")?.1; + let code: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + if code.len() == 6 { + Some(code) + } else { + None + } +} + +/// JSON `{"active": true|false|null, "sas": "XXXXXX"|null}` for the Swift shell. +/// +/// Null `active` means this tail has no lifecycle line. Caller frees a non-null +/// return with `bolt_free_string`. +/// +/// # Safety +/// `stderr` must be null or a valid nul-terminated UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn bolt_parse_wt_session_stderr(stderr: *const c_char) -> *mut c_char { + if stderr.is_null() { + return std::ptr::null_mut(); + } + let text = match CStr::from_ptr(stderr).to_str() { + Ok(s) => s, + Err(_) => return std::ptr::null_mut(), + }; + CString::new(parse_wt_session_stderr(text).json()) + .map(|cs| cs.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CStr; + + /// Oct 24, 2026 LAN capture: Studio Chrome → MacBook, after AllowAlways. + /// The shell stayed on "Waiting for encrypted channel" because none of + /// these lines contain `ACTIVE_SESSION registered`. + fn browser_wt_established() -> &'static str { + "\ +[WT_HELLO] 192.168.4.25:55170 HELLO ok: identity=abc, caps=[\"bolt.transfer-ratchet-v1\"] +[SAS] 5BE04E +[WT_SESSION] 192.168.4.25:55170 session established, entering shared session loop +[BTR] 192.168.4.25:55170 engine initialized (generation=0) +[SESSION] 192.168.4.25:55170 active session handle registered +" + } + + #[test] + fn browser_wt_session_is_active_with_sas_without_the_old_needle() { + let sample = browser_wt_established(); + assert!( + !sample.contains("ACTIVE_SESSION registered"), + "the daemon does not print the needle the shell used to require" + ); + assert!(!sample.contains("ACTIVE_SESSION cleared")); + + let parsed = parse_wt_session_stderr(sample); + assert_eq!(parsed.active, Some(true)); + assert_eq!(parsed.sas.as_deref(), Some("5BE04E")); + } + + #[test] + fn early_wt_logs_are_not_a_session() { + // These are printed before pairing and before the shared loop. Treating + // them as "up" would leave Establishing while the ask sheet is still open, + // then stick on Connected if the user denies. + let sample = "\ +[WT_SESSION] 10.0.0.1:1 session request: authority=mac.local, path=/ +[WT_SESSION] 10.0.0.1:1 session accepted +[WT_SESSION] 10.0.0.1:1 bidi stream opened +[WT_HELLO] 10.0.0.1:1 HELLO ok: identity=abc, caps=[] +[PAIRING_DENIED] WT_SESSION 10.0.0.1:1 policy denied identity=abc +"; + assert_eq!(parse_wt_session_stderr(sample).active, None); + } + + #[test] + fn hello_ok_alone_is_not_a_session() { + let sample = "\ +[WT_HELLO] 192.168.4.25:55170 HELLO ok: identity=abc, caps=[] +[SAS] 5BE04E +"; + let parsed = parse_wt_session_stderr(sample); + assert_eq!(parsed.active, None); + assert_eq!(parsed.sas, None); + } + + #[test] + fn wt_established_line_alone_is_enough() { + let sample = "\ +[SAS] 5BE04E +[WT_SESSION] 10.0.0.8:443 session established, entering shared session loop +"; + let parsed = parse_wt_session_stderr(sample); + assert_eq!(parsed.active, Some(true)); + assert_eq!(parsed.sas.as_deref(), Some("5BE04E")); + } + + #[test] + fn shared_registered_line_alone_is_enough() { + let sample = "[SESSION] 10.0.0.8:443 active session handle registered\n"; + let parsed = parse_wt_session_stderr(sample); + assert_eq!(parsed.active, Some(true)); + assert_eq!(parsed.sas, None); + } + + #[test] + fn websocket_established_line_is_not_this_latch() { + // WS emits session.connected on IPC and uses a different tag. Matching + // it here would race the IPC path. The shared registered line, which + // follows it, is still a real session. + let only_ws = "[WS_SESSION] 10.0.0.8:9 session established, entering message loop\n"; + assert_eq!(parse_wt_session_stderr(only_ws).active, None); + + let with_register = "\ +[WS_SESSION] 10.0.0.8:9 session established, entering message loop +[SESSION] 10.0.0.8:9 active session handle registered +"; + assert_eq!(parse_wt_session_stderr(with_register).active, Some(true)); + } + + #[test] + fn clear_after_establish_ends_the_session() { + let sample = format!( + "{}\ +[SESSION] 192.168.4.25:55170 message loop ended +[SESSION] 192.168.4.25:55170 active session handle cleared +", + browser_wt_established() + ); + let parsed = parse_wt_session_stderr(&sample); + assert_eq!(parsed.active, Some(false)); + assert_eq!(parsed.sas, None); + } + + #[test] + fn latest_establish_wins_over_a_stale_clear() { + let sample = "\ +[SESSION] 10.0.0.1:1 active session handle cleared +[SAS] AABBCC +[WT_SESSION] 10.0.0.2:2 session established, entering shared session loop +[SESSION] 10.0.0.2:2 active session handle registered +"; + let parsed = parse_wt_session_stderr(sample); + assert_eq!(parsed.active, Some(true)); + assert_eq!(parsed.sas.as_deref(), Some("AABBCC")); + } + + #[test] + fn historical_active_session_token_still_matches() { + let sample = "\ +[SAS] 00FF11 +[WT_SESSION] ACTIVE_SESSION registered +"; + // The second line also contains [WT_SESSION] but not "session established". + // The historical token must still latch. + let parsed = parse_wt_session_stderr(sample); + assert_eq!(parsed.active, Some(true)); + assert_eq!(parsed.sas.as_deref(), Some("00FF11")); + + let cleared = "[SESSION] ACTIVE_SESSION cleared\n"; + assert_eq!(parse_wt_session_stderr(cleared).active, Some(false)); + } + + #[test] + fn sas_after_establish_is_kept() { + let sample = "\ +[WT_SESSION] 10.0.0.2:2 session established, entering shared session loop +[SAS] 5BE04E +"; + let parsed = parse_wt_session_stderr(sample); + assert_eq!(parsed.active, Some(true)); + assert_eq!(parsed.sas.as_deref(), Some("5BE04E")); + } + + #[test] + fn empty_tail_has_no_lifecycle() { + let parsed = parse_wt_session_stderr(""); + assert_eq!(parsed.active, None); + assert_eq!(parsed.sas, None); + } + + #[test] + fn ffi_json_for_browser_wt_session() { + let raw = CString::new(browser_wt_established()).unwrap(); + let ptr = unsafe { bolt_parse_wt_session_stderr(raw.as_ptr()) }; + assert!(!ptr.is_null()); + let json = unsafe { CStr::from_ptr(ptr).to_string_lossy().into_owned() }; + unsafe { crate::bolt_free_string(ptr) }; + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["active"], true); + assert_eq!(value["sas"], "5BE04E"); + } + + #[test] + fn ffi_null_input_returns_null() { + let ptr = unsafe { bolt_parse_wt_session_stderr(std::ptr::null()) }; + assert!(ptr.is_null()); + } +}