Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 60 additions & 45 deletions native/macos/Sources/LocalBolt/BoltBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?) {
Expand Down Expand Up @@ -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
Expand Down
50 changes: 39 additions & 11 deletions native/macos/Sources/LocalBolt/LocalBoltApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions native/shared/include/bolt_native_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions native/shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
Loading