diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index e6c79eb..9af5e94 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -394,6 +394,94 @@ function resolveWindowIcon(): string { return path.join(base, 'icon.png'); } +/** + * Well-known per-user tool dirs appended to the inherited PATH so npm/cargo + * resolve when Electron launches us from a desktop launcher (mirrors the + * backend's augmented_path() in src/updates.rs). + */ +function augmentedPath(): string { + const home = process.env.HOME ?? ''; + const extra = [ + `${home}/.cargo/bin`, + `${home}/.local/bin`, + `${home}/.mise/shims`, + `${home}/.asdf/shims`, + '/usr/local/bin', + ]; + const inherited = process.env.PATH ?? ''; + return [...inherited.split(':').filter(Boolean), ...extra].join(':'); +} + +/** Run a build command to completion, resolving with its combined output. */ +function runBuild( + cwd: string, + program: string, + args: string[], +): Promise<{ ok: boolean; output: string }> { + return new Promise((resolve) => { + const child = spawn(program, args, { + cwd, + env: { ...process.env, PATH: augmentedPath() }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout?.on('data', (chunk: Buffer) => (output += chunk.toString())); + child.stderr?.on('data', (chunk: Buffer) => (output += chunk.toString())); + child.on('error', (err) => resolve({ ok: false, output: `${program}: ${err.message}` })); + child.on('close', (code) => resolve({ ok: code === 0, output })); + }); +} + +let selfUpdateInFlight: Promise<{ reloaded: boolean; note: string }> | null = null; + +/** + * Rebuild the app from its source checkout and hot-swap the running parts: + * fresh `dist/` + a freshly spawned backend, then reload every window. The + * Electron shell keeps running, so the user does not restart the app. + * (A change to this main-process file itself still needs a manual relaunch.) + */ +async function selfUpdate(): Promise<{ reloaded: boolean; note: string }> { + if (selfUpdateInFlight) return selfUpdateInFlight; + selfUpdateInFlight = (async () => { + if (app.isPackaged) { + throw new Error('This build is packaged; updates are applied by replacing the app bundle.'); + } + const frontendDir = app.getAppPath(); + const projectRoot = path.join(frontendDir, '..'); + + const npmBuild = await runBuild(frontendDir, 'npm', ['run', 'build']); + if (!npmBuild.ok) { + throw new Error(`Frontend build failed:\n${npmBuild.output.slice(-2000)}`); + } + const cargoBuild = await runBuild(projectRoot, 'cargo', ['build', '--release']); + if (!cargoBuild.ok) { + throw new Error(`Rust build failed:\n${cargoBuild.output.slice(-2000)}`); + } + + // Swap the backend: kill the old child; the next request spawns the new + // binary. startBackend() re-checks exitCode, so resetting the flag is + // enough once the process is actually gone. + if (backend && backend.exitCode === null) { + backend.kill(); + await new Promise((resolve) => { + if (!backend) return resolve(); + backend.once('exit', () => resolve()); + setTimeout(resolve, 3000); + }); + } + backend = null; + backendStarted = false; + + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.reloadIgnoringCache(); + } + return { reloaded: true, note: 'Rebuilt and reloaded. The app stayed open.' }; + })().finally(() => { + selfUpdateInFlight = null; + }); + return selfUpdateInFlight; +} + function startBackend(): void { if (backendStarted && backend && backend.exitCode === null) { return; @@ -985,6 +1073,8 @@ app.whenReady().then(() => { }, ); + ipcMain.handle('app:selfupdate', () => selfUpdate()); + ipcMain.handle('clipboard:write', (_event, text: string) => { clipboard.writeText(String(text)); return true; diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index 93db526..3c7e5ac 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -4,4 +4,6 @@ contextBridge.exposeInMainWorld('backend', { request: (method: string, params?: Record): Promise => ipcRenderer.invoke('backend:request', { method, params }), copyText: (text: string): Promise => ipcRenderer.invoke('clipboard:write', text), + selfUpdate: (): Promise<{ reloaded: boolean; note: string }> => + ipcRenderer.invoke('app:selfupdate'), }); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fc67375..3f1d0db 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -25,6 +25,8 @@ declare global { backend: { request(method: string, params?: Record): Promise; copyText(text: string): Promise; + /** Rebuild + hot-reload the app without a manual restart (Electron only). */ + selfUpdate?(): Promise<{ reloaded: boolean; note: string }>; }; } } diff --git a/frontend/src/screens/SettingsScreen.tsx b/frontend/src/screens/SettingsScreen.tsx index 312bf32..e2bc2bb 100644 --- a/frontend/src/screens/SettingsScreen.tsx +++ b/frontend/src/screens/SettingsScreen.tsx @@ -73,7 +73,25 @@ export function SettingsScreen() { const result = await updateApply(); const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)]; if (result.restart_required) { - lines.push('Rebuild and restart the app (cargo build --release, then relaunch) to finish.'); + // Rebuild in place and hot-swap the backend + renderer. The Electron + // shell itself never restarts; only a change to the main process file + // (this code's own host) still needs a manual relaunch. + if (window.backend.selfUpdate) { + lines.push('Rebuilding and reloading the app…'); + setApplyMessage(lines); + try { + const update = await window.backend.selfUpdate(); + lines.push(update.note); + } catch (err) { + lines.push( + `Automatic reload failed: ${err instanceof Error ? err.message : String(err)}. Rebuild and restart manually to finish.`, + ); + } + } else { + lines.push( + 'Rebuild and restart the app (cargo build --release, then relaunch) to finish.', + ); + } } setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']); } catch (err) {