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.
710use std:: net:: TcpStream ;
11+ use std:: sync:: atomic:: { AtomicBool , Ordering } ;
12+ use std:: sync:: Arc ;
813use std:: time:: { Duration , Instant } ;
914
15+ use base64:: engine:: general_purpose:: STANDARD as BASE64 ;
16+ use base64:: Engine as _;
1017use tauri:: webview:: PageLoadEvent ;
1118use tauri:: { Manager , RunEvent , WebviewUrl , WebviewWindowBuilder } ;
1219use tauri_plugin_opener:: OpenerExt ;
1320use 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-
3548const PORT : u16 = 34115 ;
3649
3750/// Holds the sidecar server process so it can be killed when the app exits.
3851#[ cfg( not( debug_assertions) ) ]
3952struct 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 ( '&' , "&" )
103+ . replace ( '<' , "<" )
104+ . replace ( '>' , ">" )
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 ( ( ) )
0 commit comments