feat(updater): apply updates without restarting the app
New app:selfupdate IPC (main process): npm run build + cargo build --release with the augmented PATH, then kill the backend child, reset the spawn flag so the next request starts the NEW binary, and reloadIgnoringCache every window. The Electron shell keeps running — no manual restart. Settings install now triggers it automatically when restart_required. Honest limits: a change to the Electron main process itself still needs one manual relaunch, and packaged builds report that bundle replacement is the update path.
This commit is contained in:
parent
4cc04812fe
commit
dcc701f88b
4 changed files with 113 additions and 1 deletions
|
|
@ -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<void>((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;
|
||||
|
|
|
|||
|
|
@ -4,4 +4,6 @@ contextBridge.exposeInMainWorld('backend', {
|
|||
request: (method: string, params?: Record<string, unknown>): Promise<unknown> =>
|
||||
ipcRenderer.invoke('backend:request', { method, params }),
|
||||
copyText: (text: string): Promise<void> => ipcRenderer.invoke('clipboard:write', text),
|
||||
selfUpdate: (): Promise<{ reloaded: boolean; note: string }> =>
|
||||
ipcRenderer.invoke('app:selfupdate'),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue