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:
@@ -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:<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.
|
||||
@@ -115,6 +116,7 @@ async function startServerAndWindow(dataRoot: string): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<EmbeddedServer> {
|
||||
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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user