From 1e8588b0f644196490c85383cc5172ded061698b Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Tue, 4 Aug 2026 23:36:27 +0800 Subject: [PATCH] fix(desktop): keep the app origin stable so UI preferences survive restarts (#199) Co-authored-by: Claude Fable 5 --- packages/desktop/src/main.ts | 4 +- packages/desktop/src/port-memory.ts | 66 +++++++++++++++++ packages/desktop/src/server-process.ts | 12 ++- packages/desktop/test/port-memory.test.ts | 90 +++++++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 packages/desktop/src/port-memory.ts create mode 100644 packages/desktop/test/port-memory.test.ts diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 4999157..9820061 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -2,7 +2,8 @@ * 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 + * shared data root (PENGUIN_HOME or ~/.penguin/data), learn its port (last launch's when + * still free, so origin-scoped localStorage preferences survive restarts), and load * `http://localhost:/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. @@ -115,6 +116,7 @@ async function startServerAndWindow(dataRoot: string): Promise { const started = await startEmbeddedServer({ dataRoot, portFile: path.join(app.getPath("userData"), "server-port"), + preferredPortFile: path.join(app.getPath("userData"), "preferred-port"), log: (chunk) => process.stdout.write(`[server] ${chunk}`), }); server = started; diff --git a/packages/desktop/src/port-memory.ts b/packages/desktop/src/port-memory.ts new file mode 100644 index 0000000..393489f --- /dev/null +++ b/packages/desktop/src/port-memory.ts @@ -0,0 +1,66 @@ +/** + * Port stickiness across launches. The renderer's localStorage (theme, language, panel + * layout — the web app's persistence mechanism) is scoped to the app origin + * `http://localhost:`, so a port that changes every launch silently drops every + * user preference. The shell therefore remembers the port the server actually bound and + * asks for it again next launch — PORT=0 stays the allocator (first launch, or whenever + * the remembered port is taken), so no fixed number is introduced that could clash with + * other installs or dev servers. + * + * No Electron imports, so this unit-tests under plain vitest. + */ +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { parsePortFile } from "./util.js"; + +/** Reads the remembered port (same format as the announcement file); null when absent or invalid. */ +export function readPreferredPort(file: string): number | null { + try { + return parsePortFile(fs.readFileSync(file, "utf8")); + } catch { + return null; + } +} + +/** Records the port the server actually bound, for the next launch to prefer. */ +export function rememberPreferredPort(file: string, port: number): void { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${port}\n`); + } catch { + // Best-effort: losing the memory only costs origin stickiness next launch. + } +} + +/** + * Whether a bind on host:port would succeed. Only EADDRINUSE / EACCES veto the port; + * any other error (e.g. no IPv6 stack for `::1`) says nothing about availability. + */ +function portIsFree(port: number, host: string): Promise { + return new Promise((resolve) => { + const probe = net.createServer(); + probe.unref(); + probe.once("error", (err: NodeJS.ErrnoException) => { + resolve(err.code !== "EADDRINUSE" && err.code !== "EACCES"); + }); + probe.listen({ port, host, exclusive: true }, () => { + probe.close(() => resolve(true)); + }); + }); +} + +/** + * The port to request for this launch: the preferred (last bound) port when it is still + * free, else 0 for a fresh OS-assigned one. Both loopback stacks are probed — the server + * binds 127.0.0.1 and mirrors onto `::1`, and a foreign listener on either would let the + * window's `localhost` resolution reach the wrong process. + */ +export async function choosePort(preferred: number | null): Promise { + if (preferred === null) return 0; + const free = await Promise.all([ + portIsFree(preferred, "127.0.0.1"), + portIsFree(preferred, "::1"), + ]); + return free.every(Boolean) ? preferred : 0; +} diff --git a/packages/desktop/src/server-process.ts b/packages/desktop/src/server-process.ts index 6d76cce..0094175 100644 --- a/packages/desktop/src/server-process.ts +++ b/packages/desktop/src/server-process.ts @@ -11,6 +11,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { app, utilityProcess } from "electron"; import type { UtilityProcess } from "electron"; +import { choosePort, readPreferredPort, rememberPreferredPort } from "./port-memory.js"; import { appOriginFor, parsePortFile } from "./util.js"; export interface EmbeddedServer { @@ -103,17 +104,21 @@ async function waitForHttp(origin: string, exited: () => boolean): Promise } /** - * 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 + * Starts the embedded server on the given data root — preferring last launch's port so + * the app origin (and the renderer's origin-scoped localStorage: theme, language, …) + * stays stable across launches, with PORT=0 as the fallback allocator — 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; + preferredPortFile: string; log: (chunk: string) => void; }): Promise { const token = randomBytes(32).toString("base64url"); fs.rmSync(opts.portFile, { force: true }); + const requestedPort = await choosePort(readPreferredPort(opts.preferredPortFile)); const child = utilityProcess.fork(serverEntryPath(), [], { serviceName: "penguin-server", stdio: "pipe", @@ -122,7 +127,7 @@ export async function startEmbeddedServer(opts: { ...bundledShellEnv(), PENGUIN_HOME: opts.dataRoot, HOST: "127.0.0.1", - PORT: "0", + PORT: String(requestedPort), PENGUIN_DESKTOP_TOKEN: token, PENGUIN_PORT_FILE: opts.portFile, }, @@ -135,6 +140,7 @@ export async function startEmbeddedServer(opts: { }); const port = await waitForPortFile(opts.portFile, () => exited); + rememberPreferredPort(opts.preferredPortFile, port); const origin = appOriginFor(port); await waitForHttp(origin, () => exited); return { child, origin, token }; diff --git a/packages/desktop/test/port-memory.test.ts b/packages/desktop/test/port-memory.test.ts new file mode 100644 index 0000000..3fe28d7 --- /dev/null +++ b/packages/desktop/test/port-memory.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { choosePort, readPreferredPort, rememberPreferredPort } from "../src/port-memory.js"; + +let tmpDir: string | null = null; + +function memoryFile(): string { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "penguin-port-memory-")); + return path.join(tmpDir, "nested", "preferred-port"); +} + +afterEach(() => { + if (tmpDir !== null) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = null; +}); + +function listen(host: string, port = 0): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen({ port, host, exclusive: true }, () => resolve(server)); + }); +} + +function close(server: net.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +/** + * An OS-assigned port that is free on both loopback stacks (choosePort probes both, and + * on a shared machine `::1:` may belong to someone else). A missing IPv6 stack + * does not disqualify the port — choosePort ignores it the same way. + */ +async function freeLoopbackPort(): Promise { + for (;;) { + const v4 = await listen("127.0.0.1"); + const port = (v4.address() as net.AddressInfo).port; + try { + const v6 = await listen("::1", port); + await close(v6); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") { + await close(v4); + continue; + } + } + await close(v4); + return port; + } +} + +describe("preferred-port memory file", () => { + it("round-trips a remembered port, creating parent directories", () => { + const file = memoryFile(); + expect(readPreferredPort(file)).toBeNull(); + rememberPreferredPort(file, 41873); + expect(readPreferredPort(file)).toBe(41873); + }); + + it("treats a corrupt file as no memory", () => { + const file = memoryFile(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, "not a port\n"); + expect(readPreferredPort(file)).toBeNull(); + }); +}); + +describe("choosePort", () => { + it("asks for an ephemeral port when there is no memory", async () => { + expect(await choosePort(null)).toBe(0); + }); + + it("reuses the preferred port while it is free", async () => { + const port = await freeLoopbackPort(); + expect(await choosePort(port)).toBe(port); + }); + + it("falls back to an ephemeral port when the preferred one is taken", async () => { + const squatter = await listen("127.0.0.1"); + const port = (squatter.address() as net.AddressInfo).port; + try { + expect(await choosePort(port)).toBe(0); + } finally { + await close(squatter); + } + }); +});