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.