/ electron / main.ts
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  }