11const crypto = require ( "crypto" ) ;
22const fs = require ( "fs" ) ;
33const path = require ( "path" ) ;
4- const { pathToFileURL } = require ( "url" ) ;
4+ const net = require ( "net" ) ;
5+ const { spawn } = require ( "child_process" ) ;
56const { app, BrowserWindow, shell, dialog } = require ( "electron" ) ;
67const { autoUpdater } = require ( "electron-updater" ) ;
78
89const APP_HOST = "127.0.0.1" ;
910const APP_PORT = 5510 ;
10- const SERVER_READY_TIMEOUT_MS = 20000 ;
11+ const SERVER_READY_TIMEOUT_MS = 60000 ;
12+ const SERVER_READY_RETRY_MS = 300 ;
1113const UPDATE_CHECK_DELAY_MS = 5000 ;
12- const STARTUP_LOG_RELATIVE_PATH = path . join ( "logs" , "startup.log" ) ;
14+ const DESKTOP_STARTUP_LOG_FILENAME = "desktop-startup.log" ;
15+ const STARTUP_LOG_PREVIEW_LINES = 20 ;
1316let updaterInitialized = false ;
17+ let selectedPort = APP_PORT ;
18+ let startupLogPath = path . resolve ( process . cwd ( ) , DESKTOP_STARTUP_LOG_FILENAME ) ;
19+ let embeddedServerProcess = null ;
20+ let isQuittingApp = false ;
21+ let bootstrapCompleted = false ;
22+ const startupDebugEnabled = process . env . SINGBETTER_DEBUG_STARTUP === "1" ;
1423
1524const logDesktop = ( message ) => {
1625 console . log ( `[desktop] ${ message } ` ) ;
@@ -20,16 +29,29 @@ const ensureDir = (targetPath) => {
2029 fs . mkdirSync ( targetPath , { recursive : true } ) ;
2130} ;
2231
23- const appendStartupLog = ( userDataDir , message , error ) => {
32+ const getStartupLogPath = ( ) => {
33+ if ( startupLogPath ) return startupLogPath ;
2434 try {
25- const logsDir = path . join ( userDataDir , "logs" ) ;
26- ensureDir ( logsDir ) ;
27- const startupLogPath = path . join ( logsDir , "startup.log" ) ;
35+ const userDataDir = app . getPath ( "userData" ) ;
36+ ensureDir ( userDataDir ) ;
37+ startupLogPath = path . join ( userDataDir , DESKTOP_STARTUP_LOG_FILENAME ) ;
38+ return startupLogPath ;
39+ } catch ( _error ) {
40+ // ignore and fallback
41+ }
42+ startupLogPath = path . resolve ( process . cwd ( ) , DESKTOP_STARTUP_LOG_FILENAME ) ;
43+ return startupLogPath ;
44+ } ;
45+
46+ const appendStartupLog = ( message , error ) => {
47+ try {
48+ const logPath = getStartupLogPath ( ) ;
49+ ensureDir ( path . dirname ( logPath ) ) ;
2850 const errorSuffix = error
2951 ? ` | error=${ error instanceof Error ? error . stack || error . message : String ( error ) } `
3052 : "" ;
3153 fs . appendFileSync (
32- startupLogPath ,
54+ logPath ,
3355 `[${ new Date ( ) . toISOString ( ) } ] ${ message } ${ errorSuffix } \n` ,
3456 "utf8" ,
3557 ) ;
@@ -38,6 +60,53 @@ const appendStartupLog = (userDataDir, message, error) => {
3860 }
3961} ;
4062
63+ const readStartupLogTail = ( lineCount = STARTUP_LOG_PREVIEW_LINES ) => {
64+ try {
65+ const logPath = getStartupLogPath ( ) ;
66+ if ( ! fs . existsSync ( logPath ) ) return "(log file does not exist yet)" ;
67+ const lines = fs
68+ . readFileSync ( logPath , "utf8" )
69+ . split ( / \r ? \n / )
70+ . filter ( Boolean ) ;
71+ if ( lines . length === 0 ) return "(log file is empty)" ;
72+ return lines . slice ( - lineCount ) . join ( "\n" ) ;
73+ } catch ( error ) {
74+ return `Unable to read startup log tail: ${ error instanceof Error ? error . message : String ( error ) } ` ;
75+ }
76+ } ;
77+
78+ const showStartupFailureDialog = ( error , title = "Desktop failed to start embedded server" ) => {
79+ const message = error instanceof Error ? error . stack || error . message : String ( error ) ;
80+ appendStartupLog ( title , error ) ;
81+ const details = [
82+ title ,
83+ `Log file: ${ getStartupLogPath ( ) } ` ,
84+ "" ,
85+ "Recent log lines:" ,
86+ readStartupLogTail ( ) ,
87+ ] . join ( "\n" ) ;
88+ dialog . showErrorBox ( "Desktop Startup Error" , `${ message } \n\n${ details } ` ) ;
89+ } ;
90+
91+ const installGlobalCrashHandlers = ( ) => {
92+ process . on ( "uncaughtException" , ( error ) => {
93+ appendStartupLog ( "Main process uncaughtException" , error ) ;
94+ showStartupFailureDialog ( error ) ;
95+ if ( ! bootstrapCompleted ) {
96+ app . quit ( ) ;
97+ }
98+ } ) ;
99+
100+ process . on ( "unhandledRejection" , ( reason ) => {
101+ const error = reason instanceof Error ? reason : new Error ( String ( reason ) ) ;
102+ appendStartupLog ( "Main process unhandledRejection" , error ) ;
103+ showStartupFailureDialog ( error ) ;
104+ if ( ! bootstrapCompleted ) {
105+ app . quit ( ) ;
106+ }
107+ } ) ;
108+ } ;
109+
41110const resolveFileDatabasePath = ( databaseUrl ) => {
42111 if ( ! databaseUrl || typeof databaseUrl !== "string" ) return null ;
43112 if ( ! databaseUrl . startsWith ( "file:" ) && ! databaseUrl . startsWith ( "sqlite:" ) ) return null ;
@@ -90,22 +159,57 @@ const loadOrCreateSessionSecret = (secretPath) => {
90159
91160const wait = ( ms ) => new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
92161
93- const waitForServer = async ( url ) => {
162+ const isPortAvailable = ( host , port ) =>
163+ new Promise ( ( resolve ) => {
164+ const tester = net . createServer ( ) ;
165+ tester . once ( "error" , ( ) => resolve ( false ) ) ;
166+ tester . once ( "listening" , ( ) => {
167+ tester . close ( ( ) => resolve ( true ) ) ;
168+ } ) ;
169+ tester . listen ( { host, port } ) ;
170+ } ) ;
171+
172+ const resolveDesktopPort = async ( host , preferredPort ) => {
173+ if ( await isPortAvailable ( host , preferredPort ) ) return preferredPort ;
174+ for ( let offset = 1 ; offset <= 25 ; offset += 1 ) {
175+ const candidatePort = preferredPort + offset ;
176+ if ( await isPortAvailable ( host , candidatePort ) ) {
177+ appendStartupLog ( `Port ${ preferredPort } is busy, using ${ candidatePort } instead` ) ;
178+ return candidatePort ;
179+ }
180+ }
181+ throw new Error (
182+ `No free port found near ${ preferredPort } . Close any process using localhost ports ${ preferredPort } -${ preferredPort + 25 } .` ,
183+ ) ;
184+ } ;
185+
186+ const waitForServer = async ( url , serverProcess ) => {
94187 const start = Date . now ( ) ;
188+ appendStartupLog ( `Waiting for server readiness at ${ url } /api/health` ) ;
95189 while ( Date . now ( ) - start < SERVER_READY_TIMEOUT_MS ) {
190+ if ( serverProcess && serverProcess . exitCode !== null ) {
191+ throw new Error (
192+ `Embedded server exited before readiness (code=${ serverProcess . exitCode } , signal=${ serverProcess . signalCode || "none" } )` ,
193+ ) ;
194+ }
96195 try {
97196 const response = await fetch ( `${ url } /api/health` , {
98197 method : "GET" ,
99198 } ) ;
100199 if ( response . ok || response . status === 401 || response . status === 403 ) {
200+ appendStartupLog ( `Embedded server ready at ${ url } /api/health (status ${ response . status } )` ) ;
101201 return ;
102202 }
103- } catch ( _error ) {
104- // Ignore startup race and retry.
203+ } catch ( error ) {
204+ if ( startupDebugEnabled ) {
205+ appendStartupLog ( "Server health probe failed; retrying" , error ) ;
206+ }
105207 }
106- await wait ( 300 ) ;
208+ await wait ( SERVER_READY_RETRY_MS ) ;
107209 }
108- throw new Error ( "Timed out while waiting for desktop server startup" ) ;
210+ throw new Error (
211+ `Timed out while waiting for desktop server startup at ${ url } /api/health after ${ SERVER_READY_TIMEOUT_MS } ms` ,
212+ ) ;
109213} ;
110214
111215const readUpdatePublishTarget = ( ) => {
@@ -228,10 +332,11 @@ const configureDesktopEnvironment = () => {
228332 const dbPath = path . join ( dataDir , "singbetter.db" ) ;
229333 const legacyDesktopDbPath = path . join ( dataDir , "desktop.db" ) ;
230334 const secretPath = path . join ( dataDir , "session.secret" ) ;
231- const startupLogPath = path . join ( dataDir , STARTUP_LOG_RELATIVE_PATH ) ;
335+ startupLogPath = path . join ( dataDir , DESKTOP_STARTUP_LOG_FILENAME ) ;
232336
233337 ensureDir ( dataDir ) ;
234338 ensureDir ( uploadsDir ) ;
339+ appendStartupLog ( `Desktop startup initialized userData=${ dataDir } ` ) ;
235340
236341 const envDatabasePath = resolveFileDatabasePath ( process . env . DATABASE_URL ) ;
237342 const legacyProjectDbPath = path . resolve ( process . cwd ( ) , "dev.db" ) ;
@@ -249,20 +354,20 @@ const configureDesktopEnvironment = () => {
249354 dbSource = resolved . source ;
250355 migratedFrom = resolved . migratedFrom ;
251356 } catch ( error ) {
252- appendStartupLog ( dataDir , "DB source resolution failed; continuing with fresh DB path" , error ) ;
357+ appendStartupLog ( "DB source resolution failed; continuing with fresh DB path" , error ) ;
253358 try {
254359 if ( ! fs . existsSync ( dbPath ) ) {
255360 fs . closeSync ( fs . openSync ( dbPath , "w" ) ) ;
256361 }
257362 } catch ( fileError ) {
258- appendStartupLog ( dataDir , "Failed to create fresh DB placeholder file" , fileError ) ;
363+ appendStartupLog ( "Failed to create fresh DB placeholder file" , fileError ) ;
259364 }
260365 }
261366
262367 process . env . NODE_ENV = "production" ;
263368 process . env . DESKTOP_APP = "1" ;
264369 process . env . HOST = APP_HOST ;
265- process . env . PORT = String ( APP_PORT ) ;
370+ process . env . PORT = String ( selectedPort ) ;
266371 process . env . DATABASE_URL = `file:${ dbPath } ` ;
267372 process . env . USE_JSON_DB = "false" ;
268373 process . env . AUTH_PROVIDER = process . env . AUTH_PROVIDER || "local" ;
@@ -274,6 +379,7 @@ const configureDesktopEnvironment = () => {
274379 process . env . UPLOADS_DIR = uploadsDir ;
275380 process . env . SESSION_SECRET = loadOrCreateSessionSecret ( secretPath ) ;
276381 process . env . STARTUP_LOG_PATH = startupLogPath ;
382+ process . env . CORS_ALLOWED_ORIGINS = `http://${ APP_HOST } :${ selectedPort } ` ;
277383
278384 if ( migratedFrom ) {
279385 logDesktop ( `DB resolved at: ${ dbPath } (source: migrated)` ) ;
@@ -282,8 +388,17 @@ const configureDesktopEnvironment = () => {
282388 logDesktop ( `DB resolved at: ${ dbPath } (source: ${ dbSource } )` ) ;
283389 }
284390 logDesktop (
285- `Runtime paths desktopApp=${ process . env . DESKTOP_APP } userData=${ dataDir } db=${ dbPath } uploads=${ uploadsDir } ` ,
391+ `Runtime paths desktopApp=${ process . env . DESKTOP_APP } userData=${ dataDir } db=${ dbPath } uploads=${ uploadsDir } port=${ selectedPort } ` ,
392+ ) ;
393+ appendStartupLog (
394+ `Runtime configured host=${ APP_HOST } port=${ selectedPort } db=${ dbPath } uploads=${ uploadsDir } debug=${ startupDebugEnabled ? "1" : "0" } ` ,
286395 ) ;
396+
397+ if ( startupDebugEnabled ) {
398+ appendStartupLog (
399+ `Debug startup details cwd=${ process . cwd ( ) } resourcesPath=${ process . resourcesPath } appPath=${ app . getAppPath ( ) } ` ,
400+ ) ;
401+ }
287402} ;
288403
289404const getServerEntryPath = ( ) => path . resolve ( __dirname , ".." , "dist" , "index.js" ) ;
@@ -295,10 +410,66 @@ const startEmbeddedServer = async () => {
295410 `Desktop bundle is missing server build at: ${ serverEntry } . Run 'npm run build' first.` ,
296411 ) ;
297412 }
298- await import ( pathToFileURL ( serverEntry ) . href ) ;
413+
414+ const serverEnv = {
415+ ...process . env ,
416+ NODE_ENV : "production" ,
417+ DESKTOP_APP : "1" ,
418+ HOST : APP_HOST ,
419+ PORT : String ( selectedPort ) ,
420+ ELECTRON_RUN_AS_NODE : "1" ,
421+ } ;
422+
423+ appendStartupLog (
424+ `Spawning embedded server command="${ process . execPath } " args="${ serverEntry } " host=${ APP_HOST } port=${ selectedPort } ` ,
425+ ) ;
426+
427+ const child = spawn ( process . execPath , [ serverEntry ] , {
428+ cwd : path . resolve ( __dirname , ".." ) ,
429+ env : serverEnv ,
430+ stdio : [ "ignore" , "pipe" , "pipe" ] ,
431+ windowsHide : true ,
432+ } ) ;
433+ embeddedServerProcess = child ;
434+
435+ child . stdout . on ( "data" , ( chunk ) => {
436+ const output = chunk . toString ( ) . trim ( ) ;
437+ if ( ! output ) return ;
438+ appendStartupLog ( `[server:stdout] ${ output } ` ) ;
439+ if ( startupDebugEnabled ) {
440+ logDesktop ( `[server:stdout] ${ output } ` ) ;
441+ }
442+ } ) ;
443+
444+ child . stderr . on ( "data" , ( chunk ) => {
445+ const output = chunk . toString ( ) . trim ( ) ;
446+ if ( ! output ) return ;
447+ appendStartupLog ( `[server:stderr] ${ output } ` ) ;
448+ logDesktop ( `[server:stderr] ${ output } ` ) ;
449+ } ) ;
450+
451+ child . on ( "error" , ( error ) => {
452+ appendStartupLog ( "Embedded server child process error" , error ) ;
453+ if ( ! bootstrapCompleted ) {
454+ showStartupFailureDialog ( error ) ;
455+ app . quit ( ) ;
456+ }
457+ } ) ;
458+
459+ child . on ( "exit" , ( code , signal ) => {
460+ appendStartupLog ( `Embedded server child exited code=${ code ?? "null" } signal=${ signal ?? "null" } ` ) ;
461+ if ( ! bootstrapCompleted && ! isQuittingApp ) {
462+ showStartupFailureDialog (
463+ new Error ( `Embedded server exited before startup completed (code=${ code ?? "null" } , signal=${ signal ?? "null" } )` ) ,
464+ ) ;
465+ app . quit ( ) ;
466+ }
467+ } ) ;
468+
469+ return child ;
299470} ;
300471
301- const createMainWindow = async ( ) => {
472+ const createMainWindow = async ( serverUrl ) => {
302473 const windowIconPath = resolveDesktopIconPath ( ) ;
303474 const mainWindow = new BrowserWindow ( {
304475 width : 1360 ,
@@ -314,14 +485,16 @@ const createMainWindow = async () => {
314485 sandbox : true ,
315486 webSecurity : true ,
316487 allowRunningInsecureContent : false ,
317- devTools : ! app . isPackaged ,
488+ devTools : ! app . isPackaged || startupDebugEnabled ,
318489 } ,
319490 } ) ;
320491
321- const appUrl = `http://${ APP_HOST } :${ APP_PORT } ` ;
322- const appOrigin = new URL ( appUrl ) . origin ;
323- await waitForServer ( appUrl ) ;
324- await mainWindow . loadURL ( appUrl ) ;
492+ const appOrigin = new URL ( serverUrl ) . origin ;
493+ await mainWindow . loadURL ( serverUrl ) ;
494+
495+ if ( startupDebugEnabled ) {
496+ mainWindow . webContents . openDevTools ( { mode : "detach" } ) ;
497+ }
325498
326499 mainWindow . webContents . on ( "will-navigate" , ( event , url ) => {
327500 const targetOrigin = ( ( ) => {
@@ -351,22 +524,36 @@ const createMainWindow = async () => {
351524} ;
352525
353526const bootstrap = async ( ) => {
527+ selectedPort = await resolveDesktopPort ( APP_HOST , APP_PORT ) ;
354528 configureDesktopEnvironment ( ) ;
355- await startEmbeddedServer ( ) ;
356- const mainWindow = await createMainWindow ( ) ;
529+ const serverUrl = `http://${ APP_HOST } :${ selectedPort } ` ;
530+ const child = await startEmbeddedServer ( ) ;
531+ await waitForServer ( serverUrl , child ) ;
532+ const mainWindow = await createMainWindow ( serverUrl ) ;
533+ bootstrapCompleted = true ;
534+ appendStartupLog ( `Desktop startup completed successfully at ${ serverUrl } ` ) ;
357535 initAutoUpdates ( mainWindow ) ;
358536} ;
359537
360538app . whenReady ( ) . then ( async ( ) => {
361539 try {
362540 await bootstrap ( ) ;
363541 } catch ( error ) {
364- const message = error instanceof Error ? error . message : String ( error ) ;
365- dialog . showErrorBox ( "Desktop Startup Error" , message ) ;
542+ showStartupFailureDialog ( error ) ;
366543 app . quit ( ) ;
367544 }
368545} ) ;
369546
547+ installGlobalCrashHandlers ( ) ;
548+
549+ app . on ( "before-quit" , ( ) => {
550+ isQuittingApp = true ;
551+ if ( embeddedServerProcess && embeddedServerProcess . exitCode === null ) {
552+ appendStartupLog ( "Stopping embedded server process" ) ;
553+ embeddedServerProcess . kill ( ) ;
554+ }
555+ } ) ;
556+
370557app . on ( "window-all-closed" , ( ) => {
371558 if ( process . platform !== "darwin" ) {
372559 app . quit ( ) ;
@@ -376,7 +563,9 @@ app.on("window-all-closed", () => {
376563app . on ( "activate" , async ( ) => {
377564 if ( BrowserWindow . getAllWindows ( ) . length === 0 ) {
378565 try {
379- await createMainWindow ( ) ;
566+ const serverUrl = `http://${ APP_HOST } :${ selectedPort } ` ;
567+ await waitForServer ( serverUrl , embeddedServerProcess ) ;
568+ await createMainWindow ( serverUrl ) ;
380569 } catch ( _error ) {
381570 app . quit ( ) ;
382571 }
0 commit comments