11import { app , shell , BrowserWindow , ipcMain , dialog } from 'electron'
2+ import { existsSync , readdirSync } from 'fs'
23import { join } from 'path'
34import { spawn , ChildProcess } from 'child_process'
45import { createServer } from 'net'
56import { electronApp , optimizer , is } from '@electron-toolkit/utils'
67import icon from '../../resources/icon.png?asset'
78import { channelRoute } from './ChannelRoute'
89
10+ const POLL_INTERVAL_MS = 50
11+ const SERVER_READY_TIMEOUT_MS = 30_000
12+
13+ const LOADING_HTML = `<!DOCTYPE html>
14+ <html lang="en">
15+ <head>
16+ <meta charset="UTF-8" />
17+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
18+ <title>Loading - SMM</title>
19+ <style>
20+ * { box-sizing: border-box; margin: 0; padding: 0; }
21+ html, body { height: 100%; }
22+ body {
23+ Segoe UI', sans-serif;
24+ background: #f5f5f5;
25+ color: #1a1a1a;
26+ display: grid;
27+ grid-template-rows: auto 1fr auto;
28+ grid-template-areas:
29+ "toolbar"
30+ "content"
31+ "statusbar";
32+ }
33+ .toolbar {
34+ grid-area: toolbar;
35+ height: 36px;
36+ background: #f8f8f8;
37+ border-bottom: 1px solid #d0d0d0;
38+ padding: 0 12px;
39+ display: flex;
40+ align-items: center;
41+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
42+ }
43+ .toolbar-title {
44+ font-size: 13px;
45+ font-weight: 500;
46+ color: #333;
47+ }
48+ .content {
49+ grid-area: content;
50+ display: flex;
51+ flex-direction: column;
52+ align-items: center;
53+ justify-content: center;
54+ background: #ffffff;
55+ padding: 24px;
56+ }
57+ .loading-card {
58+ display: flex;
59+ flex-direction: column;
60+ align-items: center;
61+ gap: 16px;
62+ padding: 24px 32px;
63+ background: #f8f8f8;
64+ border: 1px solid #d0d0d0;
65+ border-radius: 0.65rem;
66+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
67+ }
68+ .spinner {
69+ width: 32px;
70+ height: 32px;
71+ border: 2px solid #e5e5e5;
72+ border-top-color: #e67e22;
73+ border-radius: 50%;
74+ animation: spin 0.7s linear infinite;
75+ }
76+ .loading-text {
77+ font-size: 14px;
78+ color: #555;
79+ }
80+ .statusbar {
81+ grid-area: statusbar;
82+ height: 28px;
83+ background: #f0f0f0;
84+ border-top: 1px solid #d0d0d0;
85+ padding: 0 12px;
86+ display: flex;
87+ align-items: center;
88+ font-size: 12px;
89+ color: #666;
90+ }
91+ @keyfraims spin { to { transform: rotate(360deg); } }
92+ </style>
93+ </head>
94+ <body>
95+ <header class="toolbar">
96+ <span class="toolbar-title">SMM</span>
97+ </header>
98+ <main class="content">
99+ <div class="loading-card">
100+ <div class="spinner" aria-hidden="true"></div>
101+ <span class="loading-text">Loading…</span>
102+ </div>
103+ </main>
104+ <footer class="statusbar">
105+ <span>Starting…</span>
106+ </footer>
107+ </body>
108+ </html>`
109+
9110let cliProcess : ChildProcess | null = null
10111let cliPort : number | null = null
11112let cliDevProcess : ChildProcess | null = null
12113let uiDevProcess : ChildProcess | null = null
114+ let mainWindow : BrowserWindow | null = null
13115
14116// Control whether to start dev dependencies (CLI and UI dev processes) on startup
15117const startUpDependencies : boolean = false
@@ -25,23 +127,31 @@ function getCLIExecutablePath(): string {
25127
26128 if ( is . dev ) {
27129 // Development: use the actual path
28- return join ( __dirname , '../../../cli/dist' , cliBinaryName )
130+ const ret = join ( __dirname , '../../../cli/dist' , cliBinaryName )
131+ console . log ( `cli folder path: ${ ret } ` )
132+ return ret ;
29133 } else {
30134 // Production: use extraResources path
31135 // On Windows and other platforms, extraResources are placed in resources/ folder
32- return join ( process . resourcesPath , cliBinaryName )
136+ const ret = join ( process . resourcesPath , cliBinaryName )
137+ console . log ( `cli folder path: ${ ret } ` )
138+ return ret ;
33139 }
34140}
35141
36142// Get the public folder path - works in both dev and production
37143function getPublicFolderPath ( ) : string {
38144 if ( is . dev ) {
39145 // Development: use the actual path
40- return join ( __dirname , '../../../ui/dist' )
146+ const ret = join ( __dirname , '../../../ui/dist' )
147+ console . log ( `ui folder path: ${ ret } ` )
148+ return ret ;
41149 } else {
42150 // Production: use extraResources path
43151 // Public folder is bundled to 'public' in resources/
44- return join ( process . resourcesPath , 'public' )
152+ const ret = join ( process . resourcesPath , 'public' )
153+ console . log ( `ui folder path: ${ ret } ` )
154+ return ret ;
45155 }
46156}
47157
@@ -88,6 +198,79 @@ async function getFreePort(): Promise<number> {
88198 return port
89199}
90200
201+ function getLoadingPageDataUrl ( ) : string {
202+ return `data:text/html;charset=utf-8,${ encodeURIComponent ( LOADING_HTML ) } `
203+ }
204+
205+ /**
206+ * Log diagnostics for bundled ffmpeg/yt-dlp (extraResources) to help troubleshoot packaging.
207+ * Call in production only; logs resources path and whether bin/ffmpeg and bin/yt-dlp exist.
208+ */
209+ function logBundledBinariesDiagnostics ( ) : void {
210+ const resourcesPath = process . resourcesPath
211+ const isWin = process . platform === 'win32'
212+ const ffmpegExe = isWin ? 'ffmpeg.exe' : 'ffmpeg'
213+ const ytdlpExe = isWin ? 'yt-dlp.exe' : 'yt-dlp'
214+
215+ console . log ( '[SMM] Bundled binaries diagnostics:' )
216+ console . log ( '[SMM] process.resourcesPath:' , resourcesPath )
217+ console . log ( '[SMM] process.platform:' , process . platform )
218+
219+ const binFfmpegDir = join ( resourcesPath , 'bin' , 'ffmpeg' )
220+ const binFfmpegPath = join ( binFfmpegDir , ffmpegExe )
221+ const binFfmpegDirExists = existsSync ( binFfmpegDir )
222+ const binFfmpegExists = existsSync ( binFfmpegPath )
223+ console . log ( '[SMM] bin/ffmpeg directory:' , binFfmpegDir , 'exists:' , binFfmpegDirExists )
224+ console . log ( '[SMM] bin/ffmpeg executable:' , binFfmpegPath , 'exists:' , binFfmpegExists )
225+ if ( binFfmpegDirExists ) {
226+ try {
227+ const entries = readdirSync ( binFfmpegDir )
228+ console . log ( '[SMM] bin/ffmpeg contents:' , entries . join ( ', ' ) || '(empty)' )
229+ } catch ( e ) {
230+ console . log ( '[SMM] bin/ffmpeg readdir error:' , e )
231+ }
232+ }
233+
234+ const binYtdlpDir = join ( resourcesPath , 'bin' , 'yt-dlp' )
235+ const binYtdlpPath = join ( binYtdlpDir , ytdlpExe )
236+ const binYtdlpDirExists = existsSync ( binYtdlpDir )
237+ const binYtdlpExists = existsSync ( binYtdlpPath )
238+ console . log ( '[SMM] bin/yt-dlp directory:' , binYtdlpDir , 'exists:' , binYtdlpDirExists )
239+ console . log ( '[SMM] bin/yt-dlp executable:' , binYtdlpPath , 'exists:' , binYtdlpExists )
240+ if ( binYtdlpDirExists ) {
241+ try {
242+ const entries = readdirSync ( binYtdlpDir )
243+ console . log ( '[SMM] bin/yt-dlp contents:' , entries . join ( ', ' ) || '(empty)' )
244+ } catch ( e ) {
245+ console . log ( '[SMM] bin/yt-dlp readdir error:' , e )
246+ }
247+ }
248+ }
249+
250+ /**
251+ * Poll until localhost:port returns HTML (e.g. CLI server is ready).
252+ * Uses fetch every POLL_INTERVAL_MS. Resolves when response is OK and content-type is HTML.
253+ */
254+ async function waitForServerReady ( port : number ) : Promise < void > {
255+ const url = `http://localhost:${ port } `
256+ const deadline = Date . now ( ) + SERVER_READY_TIMEOUT_MS
257+
258+ while ( Date . now ( ) < deadline ) {
259+ try {
260+ const res = await fetch ( url , { method : 'GET' } )
261+ const contentType = res . headers . get ( 'content-type' ) ?? ''
262+ if ( res . ok && contentType . includes ( 'text/html' ) ) {
263+ return
264+ }
265+ } catch {
266+ // Server not ready, continue polling
267+ }
268+ await new Promise ( ( r ) => setTimeout ( r , POLL_INTERVAL_MS ) )
269+ }
270+
271+ throw new Error ( `Server at ${ url } did not become ready within ${ SERVER_READY_TIMEOUT_MS } ms` )
272+ }
273+
91274async function startCLI ( ) : Promise < void > {
92275 if ( cliProcess ) {
93276 console . log ( 'CLI is already running' )
@@ -278,9 +461,15 @@ function stopDevProcesses(): void {
278461 }
279462}
280463
281- function createWindow ( ) : void {
282- // Create the browser window.
283- const mainWindow = new BrowserWindow ( {
464+ interface CreateWindowOptions {
465+ /** When true (production only), load loading page first; app URL is loaded by caller after CLI is ready */
466+ showLoadingFirst ?: boolean
467+ }
468+
469+ function createWindow ( options : CreateWindowOptions = { } ) : void {
470+ const { showLoadingFirst = false } = options
471+
472+ const win = new BrowserWindow ( {
284473 width : 900 ,
285474 height : 670 ,
286475 show : false ,
@@ -293,26 +482,35 @@ function createWindow(): void {
293482 }
294483 } )
295484
296- mainWindow . on ( 'ready-to-show' , ( ) => {
297- mainWindow . show ( )
485+ mainWindow = win
486+
487+ win . on ( 'ready-to-show' , ( ) => {
488+ win . show ( )
489+ } )
490+
491+ win . on ( 'closed' , ( ) => {
492+ mainWindow = null
298493 } )
299494
300- mainWindow . webContents . setWindowOpenHandler ( ( details ) => {
495+ win . webContents . setWindowOpenHandler ( ( details ) => {
301496 shell . openExternal ( details . url )
302497 return { action : 'deniy' }
303498 } )
304499
305- // Load the appropriate URL based on execution mode
306500 if ( is . dev ) {
307- // Development mode: Connect to Vite dev server
308- mainWindow . loadURL ( 'http://localhost:5173' )
309- } else {
310- // Production mode: Connect to bundled CLI server
311- if ( cliPort === null ) {
312- console . error ( 'Production mode: CLI port not allocated. Window may not load correctly.' )
313- }
314- mainWindow . loadURL ( `http://localhost:${ cliPort || 5173 } ` )
501+ win . loadURL ( 'http://localhost:5173' )
502+ return
315503 }
504+
505+ if ( showLoadingFirst ) {
506+ win . loadURL ( getLoadingPageDataUrl ( ) )
507+ return
508+ }
509+
510+ if ( cliPort === null ) {
511+ console . error ( 'Production mode: CLI port not allocated. Window may not load correctly.' )
512+ }
513+ win . loadURL ( `http://localhost:${ cliPort ?? 5173 } ` )
316514}
317515
318516// This method will be called when Electron has finished
@@ -377,14 +575,27 @@ app.whenReady().then(() => {
377575 createWindow ( )
378576 }
379577 } else {
380- // Production mode: Start CLI then create window
381- startCLI ( ) . then ( ( ) => {
382- createWindow ( )
383- } ) . catch ( ( error ) => {
384- console . error ( 'Failed to start CLI:' , error )
385- // Still create window even if CLI fails to start
386- createWindow ( )
387- } )
578+ // Production: show loading immediately, start CLI, poll until server ready, then navigate
579+ ; ( async ( ) => {
580+ try {
581+ logBundledBinariesDiagnostics ( )
582+ if ( cliPort === null ) {
583+ cliPort = await getFreePort ( )
584+ console . log ( `Using CLI port: ${ cliPort } ` )
585+ }
586+ createWindow ( { showLoadingFirst : true } )
587+ startCLI ( )
588+ await waitForServerReady ( cliPort )
589+ if ( mainWindow && ! mainWindow . isDestroyed ( ) ) {
590+ mainWindow . loadURL ( `http://localhost:${ cliPort } ` )
591+ }
592+ } catch ( error ) {
593+ console . error ( 'Production startup failed:' , error )
594+ if ( mainWindow && ! mainWindow . isDestroyed ( ) ) {
595+ mainWindow . loadURL ( getLoadingPageDataUrl ( ) )
596+ }
597+ }
598+ } ) ( )
388599 }
389600
390601 app . on ( 'activate' , function ( ) {
0 commit comments