feat(desktop): Electron shell M2 — embedded server, desktop login, instance lock (#173)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 16:57:28 +08:00
committed by GitHub
parent 045ac250e0
commit 7015fb0153
35 changed files with 1477 additions and 112 deletions
+202
View File
@@ -0,0 +1,202 @@
/**
* Desktop shell main process (design § "桌面端原型").
*
* One window over the embedded server: fork penguin-server as a utilityProcess on the
* shared data root (PENGUIN_HOME or ~/.penguin/data), learn its ephemeral port, and load
* `http://localhost:<port>/api/auth/desktop-login?token=…` — the one-shot token lands
* the window signed in as admin. The window is a plain browser environment (no preload,
* no node integration); every capability flows through the server's HTTP API.
*
* Attach mode: when a live server (e.g. `penguin web`) already owns the data root, the
* window loads that instance instead — normal login page, deliberate degradation.
*
* Smoke hook (PENGUIN_DESKTOP_SMOKE=1): after the first load settles, print a
* `DESKTOP-SMOKE-RESULT {json}` line (+ screenshot when PENGUIN_DESKTOP_SMOKE_SHOT is
* set) and quit through the regular quit path, exercising the graceful server stop.
*/
import path from "node:path";
import { app, BrowserWindow, dialog, shell } from "electron";
import { resolveRoot } from "@prismshadow/penguin-core";
import { liveServerLock } from "@prismshadow/penguin-server/lock";
import { startEmbeddedServer, stopEmbeddedServer } from "./server-process.js";
import type { EmbeddedServer } from "./server-process.js";
import { desktopLoginUrl, isAppUrl, MAX_SERVER_RESTARTS, restartDelayMs } from "./util.js";
app.setName("PenguinHarness");
let win: BrowserWindow | null = null;
let server: EmbeddedServer | null = null;
/** App origin (embedded or attached); null until boot resolves. */
let appOrigin: string | null = null;
let quitting = false;
let stopPromise: Promise<void> | null = null;
let restartAttempts = 0;
function fatal(context: string, err: unknown): void {
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
dialog.showErrorBox("PenguinHarness", `${context}\n\n${detail}`);
app.exit(1);
}
function createWindow(url: string): void {
win = new BrowserWindow({
width: 1280,
height: 860,
show: false,
autoHideMenuBar: true,
webPreferences: {
// The window is a plain browser: no Node, no preload — the minimal attack surface.
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
win.once("ready-to-show", () => win?.show());
win.on("closed", () => {
win = null;
});
// Everything off the app origin (external links, Workspace previews on the 127.0.0.1
// counterpart host) opens in the system browser; the window never leaves the app.
win.webContents.setWindowOpenHandler(({ url: target }) => {
if (!isAppUrl(target, appOrigin)) void shell.openExternal(target);
return { action: "deny" };
});
win.webContents.on("will-navigate", (event, target) => {
if (!isAppUrl(target, appOrigin)) {
event.preventDefault();
void shell.openExternal(target);
}
});
win.webContents.on("render-process-gone", () => win?.webContents.reload());
armSmokeProbe(win);
void win.loadURL(url);
}
/** Starts (or restarts) the embedded server and points the window at desktop-login. */
async function startServerAndWindow(dataRoot: string): Promise<void> {
const started = await startEmbeddedServer({
dataRoot,
portFile: path.join(app.getPath("userData"), "server-port"),
log: (chunk) => process.stdout.write(`[server] ${chunk}`),
});
server = started;
appOrigin = started.origin;
// A run that stays up for a minute is healthy: reset the restart budget so a crash
// days later starts a fresh 1s/2s/4s ladder instead of hitting the cap immediately.
const healthyTimer = setTimeout(() => {
restartAttempts = 0;
}, 60_000);
started.child.on("exit", (code) => {
clearTimeout(healthyTimer);
void handleServerExit(dataRoot, code);
});
const url = desktopLoginUrl(started.origin, started.token);
if (win === null) createWindow(url);
else void win.loadURL(url);
}
/** Unexpected server death: restart with backoff; give up with an error dialog at the cap. */
async function handleServerExit(dataRoot: string, code: number): Promise<void> {
if (quitting) return;
server = null;
if (restartAttempts >= MAX_SERVER_RESTARTS) {
fatal(`The embedded server keeps exiting (last exit code ${code}).`, "Giving up.");
return;
}
const wait = restartDelayMs(restartAttempts);
restartAttempts += 1;
process.stdout.write(`[shell] server exited (code ${code}); restarting in ${wait}ms\n`);
await new Promise((resolve) => setTimeout(resolve, wait));
if (quitting) return;
try {
await startServerAndWindow(dataRoot);
} catch (err) {
fatal("The embedded server could not be restarted.", err);
}
}
async function boot(): Promise<void> {
const dataRoot = process.env.PENGUIN_HOME ?? resolveRoot();
const existing = await liveServerLock(dataRoot);
if (existing !== null) {
// Attach mode: the one-shot token only works against a server this shell spawned,
// so the window goes through the normal login page of the existing instance.
appOrigin = `http://localhost:${existing.port}`;
process.stdout.write(`[shell] attaching to the running server at ${appOrigin}\n`);
createWindow(`${appOrigin}/`);
return;
}
await startServerAndWindow(dataRoot);
}
// --- app lifecycle ---------------------------------------------------------
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => {
if (win !== null) {
if (win.isMinimized()) win.restore();
win.focus();
}
});
app.on("window-all-closed", () => {
// macOS keeps the app alive in the Dock; elsewhere closing the window quits.
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (win === null && appOrigin !== null) createWindow(`${appOrigin}/`);
});
// Quit path: stop the embedded server gracefully first (shutdown endpoint → kill),
// then let the quit proceed. Attach mode has no child to stop.
app.on("before-quit", (event) => {
quitting = true;
if (server !== null && stopPromise === null) {
event.preventDefault();
const running = server;
server = null;
stopPromise = stopEmbeddedServer(running).finally(() => app.quit());
}
});
void app
.whenReady()
.then(() => boot().catch((err) => fatal("PenguinHarness failed to start.", err)));
}
// --- smoke hook ------------------------------------------------------------
/** Render-settle delay before sampling the page in smoke mode. */
const SMOKE_SETTLE_MS = 2500;
function armSmokeProbe(target: BrowserWindow): void {
if (process.env.PENGUIN_DESKTOP_SMOKE !== "1") return;
target.webContents.once("did-finish-load", () => {
setTimeout(() => {
void (async () => {
try {
const result = {
title: target.webContents.getTitle(),
url: target.webContents.getURL(),
origin: appOrigin,
embedded: server !== null,
};
const shot = process.env.PENGUIN_DESKTOP_SMOKE_SHOT;
if (shot) {
const image = await target.webContents.capturePage();
const { writeFileSync } = await import("node:fs");
writeFileSync(shot, image.toPNG());
}
process.stdout.write(`DESKTOP-SMOKE-RESULT ${JSON.stringify(result)}\n`);
} catch (err) {
process.stdout.write(`DESKTOP-SMOKE-RESULT ${JSON.stringify({ error: String(err) })}\n`);
} finally {
app.quit();
}
})();
}, SMOKE_SETTLE_MS);
});
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Embedded server lifecycle: forks @prismshadow/penguin-server as an Electron
* utilityProcess (same Node runtime, isolated from the main process), learns the actual
* port from the PENGUIN_PORT_FILE announcement, probes HTTP readiness, and stops the
* server gracefully — shutdown endpoint first (the only graceful path on Windows, where
* kill() is a hard TerminateProcess), then SIGTERM-equivalent kill as fallback.
*/
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { utilityProcess } from "electron";
import type { UtilityProcess } from "electron";
import { appOriginFor, parsePortFile } from "./util.js";
export interface EmbeddedServer {
child: UtilityProcess;
/** App origin, e.g. `http://localhost:53187` (always localhost — 127.0.0.1 is the preview host). */
origin: string;
/** This launch's PENGUIN_DESKTOP_TOKEN: one-shot for desktop-login, reusable for the shutdown endpoint. */
token: string;
}
/** How long the server gets to announce its port / answer HTTP before startup fails. */
const PORT_FILE_TIMEOUT_MS = 30_000;
const HTTP_READY_TIMEOUT_MS = 10_000;
/** Grace period after the shutdown request (matches the server's own ≤5s wrap-up). */
const SHUTDOWN_GRACE_MS = 6_000;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** The server package's entry file — forked by path, resolved through node_modules. */
function serverEntryPath(): string {
return fileURLToPath(import.meta.resolve("@prismshadow/penguin-server"));
}
async function waitForPortFile(file: string, exited: () => boolean): Promise<number> {
const deadline = Date.now() + PORT_FILE_TIMEOUT_MS;
for (;;) {
if (exited()) throw new Error("The embedded server exited before announcing its port.");
try {
const port = parsePortFile(fs.readFileSync(file, "utf8"));
if (port !== null) return port;
} catch {
// Not written yet.
}
if (Date.now() >= deadline) {
throw new Error("Timed out waiting for the embedded server's port announcement.");
}
await delay(100);
}
}
async function waitForHttp(origin: string, exited: () => boolean): Promise<void> {
const deadline = Date.now() + HTTP_READY_TIMEOUT_MS;
for (;;) {
if (exited()) throw new Error("The embedded server exited during startup.");
try {
// Any HTTP answer counts (the root may 302 on the preview host); manual redirect
// keeps the probe from chasing hosts.
const res = await fetch(`${origin}/`, {
redirect: "manual",
signal: AbortSignal.timeout(1000),
});
void res.body?.cancel();
return;
} catch {
// Not accepting yet.
}
if (Date.now() >= deadline) throw new Error("Timed out waiting for the embedded server.");
await delay(100);
}
}
/**
* Starts the embedded server on the given data root with an ephemeral port (PORT=0) and
* a fresh one-shot token. Resolves once HTTP answers. The caller attaches its own
* `child.on("exit", …)` restart policy after this resolves.
*/
export async function startEmbeddedServer(opts: {
dataRoot: string;
portFile: string;
log: (chunk: string) => void;
}): Promise<EmbeddedServer> {
const token = randomBytes(32).toString("base64url");
fs.rmSync(opts.portFile, { force: true });
const child = utilityProcess.fork(serverEntryPath(), [], {
serviceName: "penguin-server",
stdio: "pipe",
env: {
...process.env,
PENGUIN_HOME: opts.dataRoot,
HOST: "127.0.0.1",
PORT: "0",
PENGUIN_DESKTOP_TOKEN: token,
PENGUIN_PORT_FILE: opts.portFile,
},
});
child.stdout?.on("data", (chunk: Buffer) => opts.log(String(chunk)));
child.stderr?.on("data", (chunk: Buffer) => opts.log(String(chunk)));
let exited = false;
child.on("exit", () => {
exited = true;
});
const port = await waitForPortFile(opts.portFile, () => exited);
const origin = appOriginFor(port);
await waitForHttp(origin, () => exited);
return { child, origin, token };
}
/**
* Graceful stop: POST /api/desktop/shutdown with the shell's Bearer token, wait out the
* server's wrap-up, then kill as a last resort. Safe to call when the child already died.
*/
export async function stopEmbeddedServer(server: EmbeddedServer): Promise<void> {
let exited = false;
const exit = new Promise<void>((resolve) =>
server.child.once("exit", () => {
exited = true;
resolve();
}),
);
try {
await fetch(`${server.origin}/api/desktop/shutdown`, {
method: "POST",
headers: { authorization: `Bearer ${server.token}` },
signal: AbortSignal.timeout(3000),
});
} catch {
// Server unreachable (already dead or wedged): fall through to kill.
}
await Promise.race([exit, delay(SHUTDOWN_GRACE_MS)]);
if (!exited) {
server.child.kill();
await Promise.race([exit, delay(2000)]);
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Pure helpers for the desktop shell — no Electron imports, so they unit-test under
* plain vitest.
*/
/** Parses the server's port-announcement file: a decimal port on the first line. */
export function parsePortFile(content: string): number | null {
const m = /^(\d{1,5})\s*$/.exec(content.trim());
if (!m) return null;
const port = Number(m[1]);
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
}
/**
* The App origin for a port. Always `localhost`: on loopback the App is canonicalized
* onto localhost and `127.0.0.1` is reserved as the preview host, which rejects /api
* (see design § "桌面端原型 · 进程模型").
*/
export function appOriginFor(port: number): string {
return `http://localhost:${port}`;
}
/** The window's first navigation: redeems the shell's one-shot token for a cookie session. */
export function desktopLoginUrl(origin: string, token: string): string {
return `${origin}/api/auth/desktop-login?token=${encodeURIComponent(token)}`;
}
/**
* Whether a navigation target stays inside the app window. Only the app origin itself
* qualifies; everything else (external sites, and Workspace previews on the 127.0.0.1
* counterpart host) opens in the system browser.
*/
export function isAppUrl(url: string, origin: string | null): boolean {
if (origin === null) return false;
try {
return new URL(url).origin === origin;
} catch {
return false;
}
}
/** Max automatic server restarts before giving up with an error dialog. */
export const MAX_SERVER_RESTARTS = 3;
/** Restart backoff: 1s, 2s, 4s (attempt is 0-based). */
export function restartDelayMs(attempt: number): number {
return Math.min(1000 * 2 ** attempt, 8000);
}