main.ts
1 import { app, BrowserWindow, dialog, nativeImage, shell } from 'electron' 2 import fs from 'node:fs' 3 import path from 'node:path' 4 import { resolveRuntimePaths, RuntimePaths } from './paths' 5 import { ServerHandle, startEmbeddedServer, tailLogFile } from './server-lifecycle' 6 import { buildAppMenu } from './menu' 7 8 const DEV_URL_DEFAULT = 'http://127.0.0.1:3456' 9 const LOG_TAIL_BYTES = 1500 10 11 let mainWindow: BrowserWindow | null = null 12 let serverHandle: ServerHandle | null = null 13 let serverLogFile: string | null = null 14 let isQuitting = false 15 16 const gotLock = app.requestSingleInstanceLock() 17 if (!gotLock) { 18 app.quit() 19 } else { 20 app.on('second-instance', () => { 21 if (mainWindow) { 22 if (mainWindow.isMinimized()) mainWindow.restore() 23 mainWindow.focus() 24 } 25 }) 26 27 app.on('ready', () => void onReady()) 28 29 app.on('window-all-closed', () => { 30 if (process.platform !== 'darwin') app.quit() 31 }) 32 33 app.on('activate', () => { 34 if (mainWindow !== null) return 35 if (serverHandle) { 36 createMainWindow(serverHandle.url) 37 } else if (!app.isPackaged) { 38 createMainWindow(process.env.SWARMCLAW_DEV_URL || DEV_URL_DEFAULT) 39 } 40 }) 41 42 app.on('before-quit', () => { 43 isQuitting = true 44 }) 45 46 app.on('will-quit', async (event) => { 47 if (!serverHandle) return 48 event.preventDefault() 49 try { 50 await serverHandle.stop() 51 } finally { 52 serverHandle = null 53 app.exit(0) 54 } 55 }) 56 } 57 58 async function onReady(): Promise<void> { 59 const paths = resolveRuntimePaths() 60 buildAppMenu(paths, () => mainWindow) 61 62 const iconPath = resolveIconPath() 63 if (process.platform === 'darwin' && iconPath && app.dock) { 64 const img = nativeImage.createFromPath(iconPath) 65 if (!img.isEmpty()) app.dock.setIcon(img) 66 } 67 68 if (!app.isPackaged) { 69 const devUrl = process.env.SWARMCLAW_DEV_URL || DEV_URL_DEFAULT 70 console.log(`[swarmclaw] dev mode, loading ${devUrl}`) 71 createMainWindow(devUrl) 72 return 73 } 74 75 serverLogFile = path.join(app.getPath('userData'), 'logs', 'server.log') 76 fs.mkdirSync(path.dirname(serverLogFile), { recursive: true }) 77 78 try { 79 serverHandle = await startEmbeddedServer({ 80 paths, 81 logFile: serverLogFile, 82 onStdout: (c) => process.stdout.write(`[swarmclaw] ${c}`), 83 onStderr: (c) => process.stderr.write(`[swarmclaw] ${c}`), 84 onExit: (code, signal) => { 85 if (!isQuitting) { 86 console.error(`[swarmclaw] server exited unexpectedly (code=${code}, signal=${signal ?? 'none'})`) 87 void showServerCrashDialog(code, signal) 88 } 89 }, 90 }) 91 } catch (err) { 92 await showStartupFailureDialog(err, paths) 93 app.exit(1) 94 return 95 } 96 97 createMainWindow(serverHandle.url) 98 void import('./updater').then((m) => m.initAutoUpdater()) 99 } 100 101 function resolveIconPath(): string | undefined { 102 const candidate = app.isPackaged 103 ? path.join(process.resourcesPath, 'icon.png') 104 : path.join(__dirname, '..', 'resources', 'icon.png') 105 return fs.existsSync(candidate) ? candidate : undefined 106 } 107 108 function createMainWindow(startUrl: string): void { 109 const iconPath = resolveIconPath() 110 mainWindow = new BrowserWindow({ 111 width: 1440, 112 height: 900, 113 minWidth: 1024, 114 minHeight: 640, 115 backgroundColor: '#0b0b0f', 116 show: true, 117 ...(iconPath ? { icon: iconPath } : {}), 118 webPreferences: { 119 contextIsolation: true, 120 nodeIntegration: false, 121 sandbox: false, 122 }, 123 }) 124 125 const wc = mainWindow.webContents 126 if (!app.isPackaged) wc.openDevTools({ mode: 'detach' }) 127 128 wc.on('did-start-loading', () => console.log('[swarmclaw] did-start-loading')) 129 wc.on('did-finish-load', () => console.log('[swarmclaw] did-finish-load')) 130 wc.on('did-fail-load', (_e, code, desc, url) => 131 console.error(`[swarmclaw] did-fail-load code=${code} desc=${desc} url=${url}`), 132 ) 133 wc.on('render-process-gone', (_e, details) => 134 console.error(`[swarmclaw] render-process-gone reason=${details.reason}`), 135 ) 136 wc.on('unresponsive', () => console.error('[swarmclaw] webContents unresponsive')) 137 138 mainWindow.on('closed', () => { 139 mainWindow = null 140 }) 141 142 mainWindow.webContents.setWindowOpenHandler(({ url }) => { 143 if (url.startsWith(startUrl)) return { action: 'allow' } 144 void shell.openExternal(url) 145 return { action: 'deny' } 146 }) 147 148 void mainWindow.loadURL(startUrl).catch((err) => { 149 console.error('[swarmclaw] loadURL rejected:', err) 150 }) 151 } 152 153 async function showServerCrashDialog(code: number | null, signal: NodeJS.Signals | null): Promise<void> { 154 const buttons = serverLogFile ? ['Open Logs Folder', 'Quit'] : ['Quit'] 155 const quitButtonId = buttons.length - 1 156 const detail = buildLogDetail(`code=${code ?? 'null'} signal=${signal ?? 'none'}`) 157 const res = await dialog.showMessageBox({ 158 type: 'error', 159 buttons, 160 defaultId: quitButtonId, 161 cancelId: quitButtonId, 162 title: 'SwarmClaw stopped', 163 message: 'The SwarmClaw server exited unexpectedly.', 164 detail, 165 }) 166 if (serverLogFile && res.response === 0) shell.showItemInFolder(serverLogFile) 167 app.exit(1) 168 } 169 170 async function showStartupFailureDialog(err: unknown, paths: RuntimePaths): Promise<void> { 171 const message = err instanceof Error ? err.message : String(err) 172 const base = `${message}\n\nStandalone entry: ${paths.standaloneEntry}\nData dir: ${paths.dataDir}` 173 const detail = buildLogDetail(base) 174 const buttons = serverLogFile ? ['Open Logs Folder', 'Quit'] : ['Quit'] 175 const quitButtonId = buttons.length - 1 176 const res = await dialog.showMessageBox({ 177 type: 'error', 178 buttons, 179 defaultId: quitButtonId, 180 cancelId: quitButtonId, 181 title: 'SwarmClaw failed to start', 182 message: 'The embedded server did not start.', 183 detail, 184 }) 185 if (serverLogFile && res.response === 0) shell.showItemInFolder(serverLogFile) 186 } 187 188 function buildLogDetail(base: string): string { 189 if (!serverLogFile) return base 190 const tail = tailLogFile(serverLogFile, LOG_TAIL_BYTES).trim() 191 if (!tail) return `${base}\n\nLog file: ${serverLogFile}\n(no output captured yet)` 192 return `${base}\n\nLog tail (${serverLogFile}):\n${tail}` 193 }