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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user