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');
|
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 {
|
function startBackend(): void {
|
||||||
if (backendStarted && backend && backend.exitCode === null) {
|
if (backendStarted && backend && backend.exitCode === null) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -985,6 +1073,8 @@ app.whenReady().then(() => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ipcMain.handle('app:selfupdate', () => selfUpdate());
|
||||||
|
|
||||||
ipcMain.handle('clipboard:write', (_event, text: string) => {
|
ipcMain.handle('clipboard:write', (_event, text: string) => {
|
||||||
clipboard.writeText(String(text));
|
clipboard.writeText(String(text));
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,6 @@ contextBridge.exposeInMainWorld('backend', {
|
||||||
request: (method: string, params?: Record<string, unknown>): Promise<unknown> =>
|
request: (method: string, params?: Record<string, unknown>): Promise<unknown> =>
|
||||||
ipcRenderer.invoke('backend:request', { method, params }),
|
ipcRenderer.invoke('backend:request', { method, params }),
|
||||||
copyText: (text: string): Promise<void> => ipcRenderer.invoke('clipboard:write', text),
|
copyText: (text: string): Promise<void> => ipcRenderer.invoke('clipboard:write', text),
|
||||||
|
selfUpdate: (): Promise<{ reloaded: boolean; note: string }> =>
|
||||||
|
ipcRenderer.invoke('app:selfupdate'),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ declare global {
|
||||||
backend: {
|
backend: {
|
||||||
request(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
request(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
||||||
copyText(text: string): Promise<void>;
|
copyText(text: string): Promise<void>;
|
||||||
|
/** Rebuild + hot-reload the app without a manual restart (Electron only). */
|
||||||
|
selfUpdate?(): Promise<{ reloaded: boolean; note: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,25 @@ export function SettingsScreen() {
|
||||||
const result = await updateApply();
|
const result = await updateApply();
|
||||||
const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)];
|
const lines = [...result.applied, ...result.failed.map((failure) => `Failed: ${failure}`)];
|
||||||
if (result.restart_required) {
|
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.']);
|
setApplyMessage(lines.length > 0 ? lines : ['Everything is already up to date.']);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue