From 0d2f456859145ff6d9a06e04cb44fd04d94f5126 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 21 Jun 2026 11:04:55 +0800 Subject: [PATCH] fix(desktop): declare Bluetooth usage strings so the sidecar stops crashing on Finder launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notarized desktop bundle hung forever on the splash ("正在启动本地服务") when launched from Finder, while `make desktop-dev` worked fine. Root cause: the sidecar links CoreBluetooth (tinygo.org/x/bluetooth, the BLE status channel). When any CoreBluetooth API is touched, macOS requires NSBluetoothAlwaysUsageDescription in the bundle Info.plist — without it the process is killed with SIGABRT (a TCC abort) before the web server ever binds, with no output on its stderr pipe. So /api/health never responds and the window never navigates off the splash. It only reproduces on a Finder/LaunchServices launch: from a terminal the responsible app already holds the Bluetooth grant, which is why dev (and running the binary by hand) worked. `jcode --version` also survives because it exits before CoreBluetooth's async state callback fires. Changes: - Add desktop/src-tauri/Info.plist with NSBluetoothAlwaysUsageDescription and NSBluetoothPeripheralUsageDescription. Tauri auto-merges src-tauri/Info.plist into the bundle Info.plist (verified: `make desktop-build` then `plutil -p .../Contents/Info.plist | grep Bluetooth`). macOS now prompts / denies gracefully instead of aborting. - Harden sidecar.rs: tee the sidecar's stdout/stderr to app_log_dir()/jcode-sidecar.log plus a recent-lines ring buffer, and surface a failure dialog (with the tail + log path) when the sidecar exits before it becomes healthy, instead of leaving the splash spinning forever. Co-Authored-By: Claude Opus 4.8 --- desktop/src-tauri/Info.plist | 22 +++++ desktop/src-tauri/src/sidecar.rs | 134 ++++++++++++++++++++++++++++--- 2 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 desktop/src-tauri/Info.plist diff --git a/desktop/src-tauri/Info.plist b/desktop/src-tauri/Info.plist new file mode 100644 index 0000000..ead3ff1 --- /dev/null +++ b/desktop/src-tauri/Info.plist @@ -0,0 +1,22 @@ + + + + + + NSBluetoothAlwaysUsageDescription + jcode uses Bluetooth to send agent status updates to a paired JCODE device. + NSBluetoothPeripheralUsageDescription + jcode uses Bluetooth to send agent status updates to a paired JCODE device. + + diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index 4c22585..c7800af 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -6,7 +6,12 @@ //! loopback port, spawns `jcode web` on it, waits until the port is live, then //! navigates the (initially hidden, splash-showing) window to the server. +use std::collections::VecDeque; +use std::io::Write as _; use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use tauri::{AppHandle, Manager, Url}; @@ -15,6 +20,14 @@ use tauri_plugin_shell::ShellExt; use crate::SidecarHandle; +/// How long we wait for the sidecar to answer /api/health before giving up. +/// 400 × 150ms ≈ 60s — generous, since first launch may compile-cache, scan +/// skills, and connect MCP servers before binding. +const HEALTH_POLL_ATTEMPTS: usize = 400; +const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(150); +/// Cap on retained sidecar log lines kept in memory for the failure dialog. +const RECENT_LINES: usize = 80; + /// Ask the OS for an unused loopback port. There is a tiny TOCTOU window /// between dropping this listener and the sidecar binding it, which is /// acceptable for a local developer tool; the health poll below tolerates a @@ -26,6 +39,18 @@ fn pick_free_port() -> u16 { .unwrap_or(8799) } +/// Path to the persisted sidecar log. Lives in the app log dir so a crash that +/// happens before the window ever loads is still inspectable after the fact — +/// the GUI swallows the sidecar's stdout/stderr otherwise. +fn sidecar_log_path(app: &AppHandle) -> PathBuf { + let dir = app + .path() + .app_log_dir() + .unwrap_or_else(|_| std::env::temp_dir()); + let _ = std::fs::create_dir_all(&dir); + dir.join("jcode-sidecar.log") +} + pub fn start(app: &AppHandle) -> Result<(), Box> { let port = pick_free_port(); let url = format!("http://127.0.0.1:{port}"); @@ -37,6 +62,8 @@ pub fn start(app: &AppHandle) -> Result<(), Box> { .home_dir() .unwrap_or_else(|_| std::env::temp_dir()); + let log_path = sidecar_log_path(app); + let (mut rx, child) = app .shell() .sidecar("jcode")? @@ -57,19 +84,59 @@ pub fn start(app: &AppHandle) -> Result<(), Box> { } } - // Pump the sidecar's stdout/stderr into the desktop log so `jcode web` - // diagnostics are still reachable when running headless inside the app. + // `ready` flips true once the window has navigated to a healthy server. + // Until then, the sidecar exiting is a fatal *startup* failure that we + // surface to the user — previously such a crash left the splash spinning + // forever, which is exactly the "stuck on 正在启动本地服务" symptom. + let ready = Arc::new(AtomicBool::new(false)); + // Ring buffer of the sidecar's most recent output lines, shown in the + // failure dialog so the user (or a bug report) captures the real panic. + let recent = Arc::new(Mutex::new(VecDeque::::with_capacity(RECENT_LINES))); + + // Pump the sidecar's stdout/stderr into the desktop log AND a persistent log + // file AND the in-memory ring buffer, so `jcode web` diagnostics survive the + // headless GUI context. On an early exit, raise the failure dialog. + let pump_app = app.clone(); + let pump_ready = ready.clone(); + let pump_recent = recent.clone(); + let pump_log = log_path.clone(); tauri::async_runtime::spawn(async move { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&pump_log) + .ok(); + + let mut record = |line: String| { + eprintln!("[jcode] {line}"); + if let Some(f) = file.as_mut() { + let _ = writeln!(f, "{line}"); + } + if let Ok(mut buf) = pump_recent.lock() { + if buf.len() >= RECENT_LINES { + buf.pop_front(); + } + buf.push_back(line); + } + }; + while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(bytes) => { - eprintln!("[jcode] {}", String::from_utf8_lossy(&bytes).trim_end()); + record(String::from_utf8_lossy(&bytes).trim_end().to_string()); } CommandEvent::Stderr(bytes) => { - eprintln!("[jcode] {}", String::from_utf8_lossy(&bytes).trim_end()); + record(String::from_utf8_lossy(&bytes).trim_end().to_string()); } CommandEvent::Terminated(payload) => { - eprintln!("[jcode] sidecar exited: {payload:?}"); + record(format!( + "sidecar exited before ready: code={:?} signal={:?}", + payload.code, payload.signal + )); + if !pump_ready.load(Ordering::SeqCst) { + surface_startup_failure(&pump_app, &pump_recent, &pump_log, payload.code); + } + break; } _ => {} } @@ -82,9 +149,15 @@ pub fn start(app: &AppHandle) -> Result<(), Box> { // the sidecar binding it, we don't navigate the window to a foreign server. let app = app.clone(); let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let poll_ready = ready.clone(); std::thread::spawn(move || { - for _ in 0..400 { + for _ in 0..HEALTH_POLL_ATTEMPTS { + // The sidecar already died (and the pump surfaced it) — stop polling. + if poll_ready.load(Ordering::SeqCst) { + return; + } if health_ok(&addr, port) { + poll_ready.store(true, Ordering::SeqCst); if let Some(w) = app.get_webview_window("main") { if let Ok(parsed) = Url::parse(&url) { let _ = w.navigate(parsed); @@ -94,18 +167,57 @@ pub fn start(app: &AppHandle) -> Result<(), Box> { } return; } - std::thread::sleep(Duration::from_millis(150)); + std::thread::sleep(HEALTH_POLL_INTERVAL); } - // Give up waiting after ~60s but still show the window (splash) so the - // user sees the failure instead of an app that never appears. - if let Some(w) = app.get_webview_window("main") { - let _ = w.show(); + // Timed out while the sidecar is (apparently) still alive but never + // became healthy. Surface it rather than leaving the splash forever. + if !poll_ready.swap(true, Ordering::SeqCst) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + } + surface_startup_failure(&app, &recent, &log_path, None); } }); Ok(()) } +/// Show a blocking error dialog describing why the local server never came up, +/// then quit. Runs on its own thread so the blocking dialog doesn't wedge the +/// async pump or the event loop. Includes the tail of the sidecar output and +/// the log path so the failure is actionable instead of a silent spinner. +fn surface_startup_failure( + app: &AppHandle, + recent: &Arc>>, + log_path: &std::path::Path, + code: Option, +) { + let tail: Vec = recent + .lock() + .map(|b| b.iter().rev().take(14).rev().cloned().collect()) + .unwrap_or_default(); + + let mut msg = String::from("jcode's local server stopped before it finished starting.\n\n"); + if let Some(c) = code { + msg.push_str(&format!("Sidecar exit code: {c}\n")); + } + msg.push_str(&format!("Log: {}\n", log_path.display())); + if !tail.is_empty() { + msg.push_str("\nRecent output:\n"); + msg.push_str(&tail.join("\n")); + } + + let app = app.clone(); + std::thread::spawn(move || { + use tauri_plugin_dialog::DialogExt; + app.dialog() + .message(msg) + .title("jcode failed to start") + .blocking_show(); + app.exit(1); + }); +} + /// Probe GET /api/health and confirm it's actually our jcode server: a 200 /// response whose body carries the health JSON ("status" field). A foreign /// listener that happened to grab the port won't satisfy both.