Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/desktop/scripts/stage-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* was skipped.
*/

import { cpSync, existsSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

Expand All @@ -16,6 +16,9 @@ const distDir = resolve(repoRoot, "dist");
const webUiIndex = resolve(distDir, "web-ui/index.html");
const cliEntry = resolve(distDir, "cli.js");
const stageDir = resolve(desktopRoot, "cli");
const runtimeVersion = JSON.parse(
readFileSync(resolve(repoRoot, "package.json"), "utf8"),
).version;

function fail(message) {
console.error(`\n[stage:cli] ERROR: ${message}\n`);
Expand Down Expand Up @@ -47,9 +50,12 @@ cpSync(distDir, stageDir, { recursive: true });
// `import` statement at module top. Drop a minimal package.json next to
// the staged cli.js so Node treats it as ESM regardless of what lives
// further up the tree.
// Embed the runtime's version next to the staged cli.js so the desktop
// shell can read the actual bundled-runtime version at boot (separate
// from `app.getVersion()`, which returns the Electron shell version).
writeFileSync(
resolve(stageDir, "package.json"),
`${JSON.stringify({ type: "module" }, null, 2)}\n`,
`${JSON.stringify({ type: "module", version: runtimeVersion }, null, 2)}\n`,
);

console.log(`[stage:cli] Staged ${distDir} → ${stageDir}`);
console.log(`[stage:cli] Staged ${distDir} → ${stageDir} (runtime ${runtimeVersion})`);
39 changes: 39 additions & 0 deletions packages/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
parseProtocolUrl,
registerProtocol,
} from "./protocol-handler.js";
import { createRuntimeAutoUpdate } from "./runtime-auto-update.js";
import { RuntimeOrchestrator } from "./runtime-orchestrator.js";
import { WindowFactory } from "./window-factory.js";
import { WindowRegistry } from "./window-registry.js";
Expand All @@ -34,11 +35,21 @@ let isQuitting = false;

const registry = new WindowRegistry();

const autoUpdate = createRuntimeAutoUpdate({
isPackaged: app.isPackaged,
userData: app.getPath("userData"),
resourcesPath: process.resourcesPath,
shellVersion: app.getVersion(),
broadcast: broadcastToAllRenderers,
});

const orchestrator = new RuntimeOrchestrator({
host: DEFAULT_HOST,
port: DEFAULT_PORT,
healthTimeoutMs: HEALTH_TIMEOUT_MS,
resolveCliShimPath,
resolveCliEntryOverride: autoUpdate?.resolveCliEntryOverride,
onCliEntryOverrideFailed: autoUpdate?.onCliEntryOverrideFailed,
});

const windowFactory = new WindowFactory({
Expand Down Expand Up @@ -83,6 +94,27 @@ orchestrator.on("url-changed", (url) => {
});
orchestrator.on("crashed", () => windowFactory.showDisconnectedScreen());

/**
* Fan an IPC notification out to every renderer. Update banners are
* global facts and should appear regardless of focused window. Uses
* `BrowserWindow.getAllWindows()` (not the registry) so transient
* windows like the OAuth popup are also covered. Best-effort: a
* destroyed-but-not-reaped window can throw synchronously.
*/
function broadcastToAllRenderers(channel: string, ...args: unknown[]): void {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed()) continue;
try {
win.webContents.send(channel, ...args);
} catch (err) {
console.warn(
`[desktop] IPC broadcast on ${channel} failed for one window:`,
err instanceof Error ? err.message : err,
);
}
}
}

function handleProtocolUrl(raw: string): void {
const parsed = parseProtocolUrl(raw);
if (!parsed) {
Expand Down Expand Up @@ -264,6 +296,8 @@ function wireAppLifecycle(): void {
windowFactory.showDisconnectedScreen();
}

// Background runtime-update checks. Packaged-only.
autoUpdate?.scheduleChecks();
});

app.on("window-all-closed", () => {
Expand All @@ -286,6 +320,11 @@ function wireAppLifecycle(): void {
// kill any post-teardown spawn.
event.preventDefault();
try {
// Stop the update timers before shutdown so a check can't
// fire mid-teardown. Any extract already past pacote.extract
// finishes cleanly and writes the pointer; an earlier-stage
// one gets dropped — its `<v>.partial/` is swept next boot.
autoUpdate?.stop();
await orchestrator.shutdown();
} catch (err) {
console.error(
Expand Down
33 changes: 31 additions & 2 deletions packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { contextBridge, ipcRenderer } from "electron";

/**
* Subscribe to a main→renderer channel and return a detach function.
* Returning detach (instead of exposing `removeListener` directly)
* prevents one renderer from removing listeners installed by another.
*/
function subscribe<T extends unknown[]>(
channel: string,
listener: (...args: T) => void,
): () => void {
const wrapped = (_e: Electron.IpcRendererEvent, ...args: T): void =>
listener(...args);
ipcRenderer.on(channel, wrapped);
return () => {
ipcRenderer.removeListener(channel, wrapped);
};
}

const desktopApi = {
platform: process.platform,

Expand All @@ -10,8 +27,20 @@ const desktopApi = {
restartRuntime(): void {
ipcRenderer.send("restart-runtime");
},

/** Fires after the background updater stages a new runtime. The
* renderer should surface a "Restart to apply <version>" banner. */
onUpdateStaged(listener: (version: string) => void): () => void {
return subscribe<[string]>("runtime:update-staged", listener);
},

/** Fires after a staged runtime failed startup and was rolled back.
* Payload is the demoted version (or `null` if unknown). */
onRuntimeRolledBack(
listener: (demotedVersion: string | null) => void,
): () => void {
return subscribe<[string | null]>("runtime:rolled-back", listener);
},
} as const;

contextBridge.exposeInMainWorld("desktop", desktopApi);

export type DesktopApi = typeof desktopApi;
Loading