Skip to content
Merged
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
22 changes: 22 additions & 0 deletions desktop/src-tauri/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
Merged into the generated bundle Info.plist by the Tauri macOS bundler.

The jcode sidecar links CoreBluetooth (the optional BLE status channel via
tinygo.org/x/bluetooth). The moment any CoreBluetooth API is touched, macOS
REQUIRES a purpose string in Info.plist — without it the process is killed
with SIGABRT (TCC abort), no stderr, before the web server ever binds. From a
terminal the responsible app already holds the Bluetooth grant so it survives,
but a Finder-launched bundle does not: that is exactly the "stuck on 正在启动
本地服务 / sidecar exited signal 6" hang. Declaring the string lets macOS show
a permission prompt (or deny gracefully) instead of aborting.
-->
<plist version="1.0">
<dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>jcode uses Bluetooth to send agent status updates to a paired JCODE device.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>jcode uses Bluetooth to send agent status updates to a paired JCODE device.</string>
</dict>
</plist>
134 changes: 123 additions & 11 deletions desktop/src-tauri/src/sidecar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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
Expand All @@ -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<dyn std::error::Error>> {
let port = pick_free_port();
let url = format!("http://127.0.0.1:{port}");
Expand All @@ -37,6 +62,8 @@ pub fn start(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
.home_dir()
.unwrap_or_else(|_| std::env::temp_dir());

let log_path = sidecar_log_path(app);

let (mut rx, child) = app
.shell()
.sidecar("jcode")?
Expand All @@ -57,19 +84,59 @@ pub fn start(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
}
}

// 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::<String>::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;
Comment thread
cnjack marked this conversation as resolved.
}
_ => {}
}
Expand All @@ -82,9 +149,15 @@ pub fn start(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
// 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);
Expand All @@ -94,18 +167,57 @@ pub fn start(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
}
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<Mutex<VecDeque<String>>>,
log_path: &std::path::Path,
code: Option<i32>,
) {
let tail: Vec<String> = 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.
Expand Down
Loading