Skip to content

Commit f558364

Browse files
committed
feat(desktop): log server output and show a diagnostic page on startup failure
The shell previously discarded the sidecar server's stdout/stderr, so a failed startup on a user machine (e.g. the Windows report in #174) left nothing to debug. Now a packaged build writes everything the server prints to server.log in the data dir, interleaved with launcher lifecycle lines stamped with seconds since launch; the previous log is kept as server.log.old. When the server dies or never spawns, the launcher bails out immediately instead of waiting the full 90 s, and the window opens on a built-in diagnostic page that explains the failure, shows the log path to attach to a bug report, and offers a retry. The launcher keeps polling behind the page and loads the app the moment the server becomes reachable, so slow first launches (antivirus scanning a fresh install) self-heal. Paths handed to the sidecar now go through dunce, stripping the Windows \\?\ extended-length prefix Tauri's resource resolver can produce, which not every Node library tolerates. Refs #174
1 parent 9cd36f5 commit f558364

5 files changed

Lines changed: 229 additions & 46 deletions

File tree

app/src-tauri/Cargo.lock

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ tauri-build = { version = "2.6.3", features = [] }
2121
serde_json = "1.0"
2222
serde = { version = "1.0", features = ["derive"] }
2323
log = "0.4"
24+
# the diagnostic page is an inline data: URL
25+
base64 = "0.22"
26+
# strips Windows `\\?\` extended-length prefixes off paths passed to the sidecar
27+
dunce = "1"
2428
# `devtools` enables right-click → Inspect in release too — useful while the desktop
2529
# shell is young (e.g. to see updater/ACL errors from the webview console).
2630
tauri = { version = "2.11.3", features = ["devtools"] }

app/src-tauri/build.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
fn main() {
2-
tauri_build::build()
2+
tauri_build::build()
33
}

app/src-tauri/src/lib.rs

Lines changed: 220 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,33 @@
44
// for it and open the window.
55
// - bundle: we start the server ourselves via the vendored `node` sidecar against
66
// the bundled `.output`, with the data/migrations/mod dirs passed in.
7+
// Everything the server prints (plus launcher lifecycle lines) goes to
8+
// `server.log` in the data dir, so a failed startup on a user's machine
9+
// is debuggable from the one file the diagnostic page points at.
710
use std::net::TcpStream;
11+
use std::sync::atomic::{AtomicBool, Ordering};
12+
use std::sync::Arc;
813
use std::time::{Duration, Instant};
914

15+
use base64::engine::general_purpose::STANDARD as BASE64;
16+
use base64::Engine as _;
1017
use tauri::webview::PageLoadEvent;
1118
use tauri::{Manager, RunEvent, WebviewUrl, WebviewWindowBuilder};
1219
use tauri_plugin_opener::OpenerExt;
1320
use tauri_plugin_window_state::{StateFlags, WindowExt};
1421

22+
#[cfg(not(debug_assertions))]
23+
use std::io::Write as _;
24+
#[cfg(not(debug_assertions))]
25+
use std::sync::Mutex;
26+
#[cfg(not(debug_assertions))]
27+
use tauri::path::BaseDirectory;
28+
#[cfg(not(debug_assertions))]
29+
use tauri_plugin_shell::{
30+
process::{CommandChild, CommandEvent},
31+
ShellExt,
32+
};
33+
1534
// External `<a>` clicks (incl. target=_blank, which on_navigation alone misses)
1635
// become a same-frame navigation, which the on_navigation hook cancels and hands to
1736
// the system browser — so links like the GitHub button open in the user's browser
@@ -26,35 +45,98 @@ window.addEventListener('click', function (e) {
2645
}, true);
2746
"#;
2847

29-
use std::sync::Mutex;
30-
#[cfg(not(debug_assertions))]
31-
use tauri::path::BaseDirectory;
32-
#[cfg(not(debug_assertions))]
33-
use tauri_plugin_shell::{process::CommandChild, ShellExt};
34-
3548
const PORT: u16 = 34115;
3649

3750
/// Holds the sidecar server process so it can be killed when the app exits.
3851
#[cfg(not(debug_assertions))]
3952
struct ServerChild(Mutex<Option<CommandChild>>);
4053

41-
/// Block until something accepts connections on the port, or time out.
42-
fn wait_for_port(port: u16, timeout: Duration) -> bool {
43-
let start = Instant::now();
44-
while start.elapsed() < timeout {
45-
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
46-
return true;
54+
/// `server.log` in the data dir: launcher lifecycle lines (stamped with seconds
55+
/// since launch) interleaved with the server's own stdout/stderr, verbatim. One log
56+
/// per launch — the previous one is kept as `server.log.old`. This file exists so
57+
/// user bug reports are debuggable without a terminal, so it must never take the app
58+
/// down with it: a failed create just disables logging.
59+
#[cfg(not(debug_assertions))]
60+
#[derive(Clone)]
61+
struct ServerLog {
62+
file: Arc<Mutex<Option<std::fs::File>>>,
63+
start: Instant,
64+
}
65+
66+
#[cfg(not(debug_assertions))]
67+
impl ServerLog {
68+
fn create(dir: &std::path::Path) -> (Self, std::path::PathBuf) {
69+
let path = dir.join("server.log");
70+
let _ = std::fs::rename(&path, dir.join("server.log.old"));
71+
let log = Self {
72+
file: Arc::new(Mutex::new(std::fs::File::create(&path).ok())),
73+
start: Instant::now(),
74+
};
75+
(log, path)
76+
}
77+
78+
/// One line of the server's own output, verbatim.
79+
fn output(&self, line: &[u8]) {
80+
if let Some(f) = self.file.lock().unwrap().as_mut() {
81+
let _ = f.write_all(line);
82+
let _ = f.write_all(b"\n");
4783
}
48-
std::thread::sleep(Duration::from_millis(150));
4984
}
50-
false
85+
86+
/// A launcher-side lifecycle line.
87+
fn line(&self, msg: &str) {
88+
let t = self.start.elapsed().as_secs_f32();
89+
if let Some(f) = self.file.lock().unwrap().as_mut() {
90+
let _ = writeln!(f, "[launcher +{t:.1}s] {msg}");
91+
}
92+
}
93+
}
94+
95+
/// The page shown when the local server didn't come up in time: says what happened
96+
/// in user terms and points at the log file to attach to a bug report. The launcher
97+
/// keeps polling the port behind it and swaps in the app the moment the server is
98+
/// reachable (first launches can be slow — e.g. antivirus scanning the fresh
99+
/// install), so the page also self-heals.
100+
fn error_page_url(log_path: Option<&str>) -> String {
101+
let escape = |s: &str| {
102+
s.replace('&', "&amp;")
103+
.replace('<', "&lt;")
104+
.replace('>', "&gt;")
105+
};
106+
let diagnostics = match log_path {
107+
Some(p) => format!(
108+
"<p>If it keeps failing, this log file records what the server said — \
109+
please attach it to a bug report:</p><p><code>{}</code></p>",
110+
escape(p)
111+
),
112+
None => "<p>Check the terminal running the dev server for errors.</p>".to_string(),
113+
};
114+
let html = format!(
115+
r#"<!doctype html><html><head><meta charset="utf-8"><title>PyOps</title><style>
116+
:root{{color-scheme:dark}}
117+
body{{margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;background:#111418;color:#e6e6e6;font:15px/1.55 system-ui,sans-serif}}
118+
main{{max-width:36rem;padding:2rem}}
119+
h1{{font-size:1.15rem;margin:0 0 .75rem}}
120+
p{{margin:.5rem 0;color:#b6bcc4}}
121+
code{{user-select:all;background:#1c2127;padding:.15rem .4rem;font-size:.85em;word-break:break-all}}
122+
button{{margin-top:1rem;background:#2563eb;border:0;color:#fff;font:inherit;padding:.5rem 1rem;cursor:pointer}}
123+
</style></head><body><main>
124+
<h1>PyOps couldn't reach its local server</h1>
125+
<p>The app runs a local server in the background, and it hasn't come up yet. This
126+
window keeps checking and loads the app automatically the moment the server is
127+
reachable — on a first launch that can take a while.</p>
128+
<p>If nothing happens for a few minutes, close the app and start it again.</p>
129+
{diagnostics}
130+
<button onclick="window.location.replace('http://localhost:{PORT}')">Retry now</button>
131+
</main></body></html>"#
132+
);
133+
format!("data:text/html;base64,{}", BASE64.encode(html))
51134
}
52135

53-
/// Open the main window (hidden) pointed at the local server, revealing it only once
54-
/// the first page has painted — so the user never sees a blank webview while the
55-
/// server boots / server-renders.
56-
fn open_main_window(app: &tauri::AppHandle) {
57-
let url = format!("http://localhost:{PORT}");
136+
/// Open the app window (label "main") at `url`. Normally hidden until the first page
137+
/// has painted — so the user never sees a blank webview while the server
138+
/// server-renders — but the diagnostic page opens visible immediately.
139+
fn open_window(app: &tauri::AppHandle, url: String, visible: bool) {
58140
// First-run size, wide enough for the desktop nav even with fractional display
59141
// scaling (the inline bar collapses to a hamburger below 1400 CSS px, and a 1.25x
60142
// scale makes the CSS viewport ~physical/1.25; the Deck's ~1280 intentionally
@@ -66,7 +148,7 @@ fn open_main_window(app: &tauri::AppHandle) {
66148
.title(title)
67149
.inner_size(1800.0, 1100.0)
68150
.min_inner_size(900.0, 600.0)
69-
.visible(false)
151+
.visible(visible)
70152
.initialization_script(EXTERNAL_LINKS_SCRIPT)
71153
.on_navigation(move |url| {
72154
// Stay on the local server; send any other web link to the system browser.
@@ -136,42 +218,137 @@ pub fn run() {
136218
)?;
137219
}
138220

221+
// Set when the server process dies (or never spawns) — lets the port
222+
// wait below bail out immediately instead of sitting through the full
223+
// timeout on a server that will never come up.
224+
let server_exited = Arc::new(AtomicBool::new(false));
225+
#[cfg(debug_assertions)]
226+
let log_path: Option<String> = None;
227+
139228
// Bundled build: start the server via the vendored node sidecar. The data
140229
// dir is the per-OS app-data dir; migrations + mod source are bundled
141230
// resources. (In dev the server is already up from beforeDevCommand.)
142231
#[cfg(not(debug_assertions))]
143-
{
144-
let server_entry =
145-
app.path().resolve("output/server/index.mjs", BaseDirectory::Resource)?;
146-
let drizzle = app.path().resolve("drizzle", BaseDirectory::Resource)?;
147-
let mod_dir = app.path().resolve("mod", BaseDirectory::Resource)?;
148-
let data_dir = app.path().app_data_dir()?;
232+
let (server_log, log_path) = {
233+
// Tauri can resolve resources to `\\?\C:\…` extended-length paths on
234+
// Windows; Node itself copes, but not every library does — pass the
235+
// plain form to the sidecar.
236+
let server_entry = dunce::simplified(
237+
&app.path()
238+
.resolve("output/server/index.mjs", BaseDirectory::Resource)?,
239+
)
240+
.to_path_buf();
241+
let drizzle =
242+
dunce::simplified(&app.path().resolve("drizzle", BaseDirectory::Resource)?)
243+
.to_path_buf();
244+
let mod_dir =
245+
dunce::simplified(&app.path().resolve("mod", BaseDirectory::Resource)?)
246+
.to_path_buf();
247+
let data_dir = dunce::simplified(&app.path().app_data_dir()?).to_path_buf();
149248
std::fs::create_dir_all(&data_dir).ok();
150249

151-
let (mut rx, child) = app
152-
.shell()
153-
.sidecar("node")?
154-
.arg(server_entry.to_string_lossy().to_string())
155-
.env("PORT", PORT.to_string())
156-
.env("HOST", "127.0.0.1")
157-
.env("PYOPS_DATA_DIR", data_dir.to_string_lossy().to_string())
158-
.env("PYOPS_MIGRATIONS_DIR", drizzle.to_string_lossy().to_string())
159-
.env("PYOPS_MOD_DIR", mod_dir.to_string_lossy().to_string())
160-
.spawn()?;
161-
app.manage(ServerChild(Mutex::new(Some(child))));
162-
// keep the pipe drained so the child never blocks on a full stdout
163-
tauri::async_runtime::spawn(async move { while rx.recv().await.is_some() {} });
164-
}
250+
let (server_log, log_file) = ServerLog::create(&data_dir);
251+
server_log.line(&format!(
252+
"PyOps v{} launching server",
253+
app.package_info().version
254+
));
255+
server_log.line(&format!("entry: {}", server_entry.display()));
256+
server_log.line(&format!("data dir: {}", data_dir.display()));
257+
258+
let spawned = app.shell().sidecar("node").and_then(|cmd| {
259+
cmd.arg(server_entry.to_string_lossy().to_string())
260+
.env("PORT", PORT.to_string())
261+
.env("HOST", "127.0.0.1")
262+
.env("PYOPS_DATA_DIR", data_dir.to_string_lossy().to_string())
263+
.env(
264+
"PYOPS_MIGRATIONS_DIR",
265+
drizzle.to_string_lossy().to_string(),
266+
)
267+
.env("PYOPS_MOD_DIR", mod_dir.to_string_lossy().to_string())
268+
.spawn()
269+
});
270+
match spawned {
271+
Ok((mut rx, child)) => {
272+
app.manage(ServerChild(Mutex::new(Some(child))));
273+
let log = server_log.clone();
274+
let exited = server_exited.clone();
275+
tauri::async_runtime::spawn(async move {
276+
while let Some(event) = rx.recv().await {
277+
match event {
278+
CommandEvent::Stdout(line) | CommandEvent::Stderr(line) => {
279+
log.output(&line)
280+
}
281+
CommandEvent::Error(e) => {
282+
log.line(&format!("server process error: {e}"))
283+
}
284+
CommandEvent::Terminated(p) => {
285+
log.line(&format!(
286+
"server exited (code {:?}, signal {:?})",
287+
p.code, p.signal
288+
));
289+
exited.store(true, Ordering::Relaxed);
290+
}
291+
_ => {}
292+
}
293+
}
294+
});
295+
}
296+
Err(e) => {
297+
server_log.line(&format!("failed to spawn the server: {e}"));
298+
server_exited.store(true, Ordering::Relaxed);
299+
}
300+
}
301+
(server_log, Some(log_file.display().to_string()))
302+
};
165303

166304
// The web UI drives the update via the updater/process plugins on launch
167305
// (guarded by window.isTauri), so nothing to spawn here.
168306

169-
// Wait for the server off the main thread, then open the window on it.
307+
// Wait for the server off the main thread: open the app window when the
308+
// port answers, or the diagnostic page if the server died / timed out.
309+
// The page isn't a dead end — we keep polling and load the app the
310+
// moment the server shows up late (slow first launches are real, e.g.
311+
// antivirus scanning the fresh install).
170312
let handle = app.handle().clone();
313+
let exited = server_exited.clone();
171314
std::thread::spawn(move || {
172-
wait_for_port(PORT, Duration::from_secs(90));
315+
let deadline = Instant::now() + Duration::from_secs(90);
316+
let ready = loop {
317+
if TcpStream::connect(("127.0.0.1", PORT)).is_ok() {
318+
break true;
319+
}
320+
if exited.load(Ordering::Relaxed) || Instant::now() >= deadline {
321+
break false;
322+
}
323+
std::thread::sleep(Duration::from_millis(150));
324+
};
325+
if ready {
326+
#[cfg(not(debug_assertions))]
327+
server_log.line("server ready, opening the app");
328+
let h = handle.clone();
329+
let _ = handle.run_on_main_thread(move || {
330+
open_window(&h, format!("http://localhost:{PORT}"), false)
331+
});
332+
return;
333+
}
334+
#[cfg(not(debug_assertions))]
335+
server_log.line("server not reachable, showing the diagnostic page");
336+
let url = error_page_url(log_path.as_deref());
173337
let h = handle.clone();
174-
let _ = handle.run_on_main_thread(move || open_main_window(&h));
338+
let _ = handle.run_on_main_thread(move || open_window(&h, url, true));
339+
loop {
340+
if TcpStream::connect(("127.0.0.1", PORT)).is_ok() {
341+
#[cfg(not(debug_assertions))]
342+
server_log.line("server came up late, loading the app");
343+
if let Some(w) = handle.get_webview_window("main") {
344+
let _ = w.eval(&format!(
345+
"window.location.replace('http://localhost:{PORT}')"
346+
));
347+
}
348+
break;
349+
}
350+
std::thread::sleep(Duration::from_secs(1));
351+
}
175352
});
176353

177354
Ok(())

app/src-tauri/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
33

44
fn main() {
5-
app_lib::run();
5+
app_lib::run();
66
}

0 commit comments

Comments
 (0)