fix(desktop): keep the app origin stable so UI preferences survive restarts (#199)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 23:36:27 +08:00
committed by GitHub
parent f8a5a1c12e
commit 1e8588b0f6
4 changed files with 168 additions and 4 deletions
+3 -1
View File
@@ -2,7 +2,8 @@
* Desktop shell main process (design § "桌面端原型"). * Desktop shell main process (design § "桌面端原型").
* *
* One window over the embedded server: fork penguin-server as a utilityProcess on the * 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:<port>/api/auth/desktop-login?token=…` — the one-shot token lands * `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, * 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. * no node integration); every capability flows through the server's HTTP API.
@@ -115,6 +116,7 @@ async function startServerAndWindow(dataRoot: string): Promise<void> {
const started = await startEmbeddedServer({ const started = await startEmbeddedServer({
dataRoot, dataRoot,
portFile: path.join(app.getPath("userData"), "server-port"), portFile: path.join(app.getPath("userData"), "server-port"),
preferredPortFile: path.join(app.getPath("userData"), "preferred-port"),
log: (chunk) => process.stdout.write(`[server] ${chunk}`), log: (chunk) => process.stdout.write(`[server] ${chunk}`),
}); });
server = started; server = started;
+66
View File
@@ -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:<port>`, 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<boolean> {
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<number> {
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;
}
+9 -3
View File
@@ -11,6 +11,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { app, utilityProcess } from "electron"; import { app, utilityProcess } from "electron";
import type { UtilityProcess } from "electron"; import type { UtilityProcess } from "electron";
import { choosePort, readPreferredPort, rememberPreferredPort } from "./port-memory.js";
import { appOriginFor, parsePortFile } from "./util.js"; import { appOriginFor, parsePortFile } from "./util.js";
export interface EmbeddedServer { export interface EmbeddedServer {
@@ -103,17 +104,21 @@ async function waitForHttp(origin: string, exited: () => boolean): Promise<void>
} }
/** /**
* Starts the embedded server on the given data root with an ephemeral port (PORT=0) and * Starts the embedded server on the given data root — preferring last launch's port so
* a fresh one-shot token. Resolves once HTTP answers. The caller attaches its own * 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. * `child.on("exit", …)` restart policy after this resolves.
*/ */
export async function startEmbeddedServer(opts: { export async function startEmbeddedServer(opts: {
dataRoot: string; dataRoot: string;
portFile: string; portFile: string;
preferredPortFile: string;
log: (chunk: string) => void; log: (chunk: string) => void;
}): Promise<EmbeddedServer> { }): Promise<EmbeddedServer> {
const token = randomBytes(32).toString("base64url"); const token = randomBytes(32).toString("base64url");
fs.rmSync(opts.portFile, { force: true }); fs.rmSync(opts.portFile, { force: true });
const requestedPort = await choosePort(readPreferredPort(opts.preferredPortFile));
const child = utilityProcess.fork(serverEntryPath(), [], { const child = utilityProcess.fork(serverEntryPath(), [], {
serviceName: "penguin-server", serviceName: "penguin-server",
stdio: "pipe", stdio: "pipe",
@@ -122,7 +127,7 @@ export async function startEmbeddedServer(opts: {
...bundledShellEnv(), ...bundledShellEnv(),
PENGUIN_HOME: opts.dataRoot, PENGUIN_HOME: opts.dataRoot,
HOST: "127.0.0.1", HOST: "127.0.0.1",
PORT: "0", PORT: String(requestedPort),
PENGUIN_DESKTOP_TOKEN: token, PENGUIN_DESKTOP_TOKEN: token,
PENGUIN_PORT_FILE: opts.portFile, PENGUIN_PORT_FILE: opts.portFile,
}, },
@@ -135,6 +140,7 @@ export async function startEmbeddedServer(opts: {
}); });
const port = await waitForPortFile(opts.portFile, () => exited); const port = await waitForPortFile(opts.portFile, () => exited);
rememberPreferredPort(opts.preferredPortFile, port);
const origin = appOriginFor(port); const origin = appOriginFor(port);
await waitForHttp(origin, () => exited); await waitForHttp(origin, () => exited);
return { child, origin, token }; return { child, origin, token };
+90
View File
@@ -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<net.Server> {
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<void> {
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:<port>` may belong to someone else). A missing IPv6 stack
* does not disqualify the port — choosePort ignores it the same way.
*/
async function freeLoopbackPort(): Promise<number> {
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);
}
});
});