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 ZhengandClaude Fable 5 authored and GitHub committed 2026-08-04 16:57:28 +08:00
1 parent 045ac250e0
commit 7015fb0153
35 files changed
+1477 -112

No files matched your search

+27 -1
View File
@@ -16,7 +16,8 @@
*/ */
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import path from "node:path"; import path from "node:path";
import { DEFAULT_SERVER_PORT } from "@prismshadow/penguin-core"; import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
import { liveServerLock } from "@prismshadow/penguin-server/lock";
import type { Command } from "commander"; import type { Command } from "commander";
import type { Messages, WebProbeFailureKind } from "../i18n.js"; import type { Messages, WebProbeFailureKind } from "../i18n.js";
@@ -84,6 +85,19 @@ export function cliEntryFor(argv1: string | undefined): string | null {
* so the values written here are the ones that take effect (options take priority over * so the values written here are the ones that take effect (options take priority over
* .env and any pre-existing env vars). * .env and any pre-existing env vars).
*/ */
/**
* Pre-start lock check: the App URL of a live server already owning the data root this
* process would use (same resolution as the server: PENGUIN_HOME or the default root),
* or null. The server itself re-checks on startup (the in-process backstop); checking
* here keeps the friendly path — `penguin server` refuses with the URL, `penguin web`
* simply opens the existing instance. Locks live per data root, so a second server on a
* DIFFERENT root is untouched. See @prismshadow/penguin-server/lock.
*/
async function existingInstanceUrl(): Promise<string | null> {
const lock = await liveServerLock(process.env.PENGUIN_HOME ?? resolveRoot());
return lock === null ? null : `http://localhost:${lock.port}/`;
}
async function startServer(opts: { async function startServer(opts: {
port?: string; port?: string;
host?: string; host?: string;
@@ -243,6 +257,12 @@ export function registerServeCommands(program: Command, t: Messages): void {
.option("--port <port>", t.serve.port) .option("--port <port>", t.serve.port)
.option("--host <host>", t.serve.host) .option("--host <host>", t.serve.host)
.action(async (opts: { port?: string; host?: string }) => { .action(async (opts: { port?: string; host?: string }) => {
const existing = await existingInstanceUrl();
if (existing !== null) {
process.stderr.write(t.serverAlreadyRunning(existing) + "\n");
process.exitCode = 1;
return;
}
await startServer(opts); await startServer(opts);
}); });
@@ -253,6 +273,12 @@ export function registerServeCommands(program: Command, t: Messages): void {
.option("--host <host>", t.serve.host) .option("--host <host>", t.serve.host)
.option("--no-open", t.serve.noOpen) .option("--no-open", t.serve.noOpen)
.action(async (opts: { port?: string; host?: string; open: boolean }) => { .action(async (opts: { port?: string; host?: string; open: boolean }) => {
const existing = await existingInstanceUrl();
if (existing !== null) {
process.stdout.write(t.webAlreadyRunning(existing) + "\n");
if (opts.open) openBrowser(existing);
return;
}
const { host, port } = await startServer(opts); const { host, port } = await startServer(opts);
const url = browserUrl(host, port); const url = browserUrl(host, port);
const readiness = await waitForReady(url); const readiness = await waitForReady(url);
+12
View File
@@ -223,6 +223,10 @@ export interface Messages {
vaultListEmpty(): string; vaultListEmpty(): string;
/** URL prompt once the `penguin web` service is ready. */ /** URL prompt once the `penguin web` service is ready. */
webReady(url: string): string; webReady(url: string): string;
/** Refusal when `penguin server` finds a live server on the same data root. */
serverAlreadyRunning(url: string): string;
/** Notice when `penguin web` finds a live server on the same data root (it opens that instance instead). */
webAlreadyRunning(url: string): string;
/** Diagnostic shown after the `penguin web` ready-poll times out (15s). */ /** Diagnostic shown after the `penguin web` ready-poll times out (15s). */
webProbeFailed(url: string, detail: string, kind: WebProbeFailureKind, port: number): string; webProbeFailed(url: string, detail: string, kind: WebProbeFailureKind, port: number): string;
} }
@@ -455,6 +459,11 @@ const en: Messages = {
vaultListTitle: () => "Vault environment variables (values masked):", vaultListTitle: () => "Vault environment variables (values masked):",
vaultListEmpty: () => "The vault is empty. Add one with `penguin config vault set`.", vaultListEmpty: () => "The vault is empty. Add one with `penguin config vault set`.",
webReady: (url) => `Web UI ready: ${url}`, webReady: (url) => `Web UI ready: ${url}`,
serverAlreadyRunning: (url) =>
`A PenguinHarness server is already running on this data root: ${url}\n` +
`Stop it first, or point PENGUIN_HOME at a separate data root.`,
webAlreadyRunning: (url) =>
`Already running on this data root — opening the existing instance: ${url}`,
webProbeFailed: (url, detail, kind, port) => { webProbeFailed: (url, detail, kind, port) => {
const hint = { const hint = {
timeout: timeout:
@@ -664,6 +673,9 @@ const zh: Messages = {
vaultListTitle: () => "vault 环境变量(值已掩码):", vaultListTitle: () => "vault 环境变量(值已掩码):",
vaultListEmpty: () => "vault 为空。用 `penguin config vault set` 添加。", vaultListEmpty: () => "vault 为空。用 `penguin config vault set` 添加。",
webReady: (url) => `Web 界面已就绪:${url}`, webReady: (url) => `Web 界面已就绪:${url}`,
serverAlreadyRunning: (url) =>
`该数据根目录已有 PenguinHarness 服务在运行:${url}\n请先停止它,或用 PENGUIN_HOME 指定另一个数据根目录。`,
webAlreadyRunning: (url) => `该数据根目录已有服务在运行,打开既有实例:${url}`,
webProbeFailed: (url, detail, kind, port) => { webProbeFailed: (url, detail, kind, port) => {
const hint = { const hint = {
timeout: `连接超时。请检查防火墙或安全软件是否拦截。请允许 PenguinHarness 在本机端口 ${port} 上通信。`, timeout: `连接超时。请检查防火墙或安全软件是否拦截。请允许 PenguinHarness 在本机端口 ${port} 上通信。`,
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@prismshadow/penguin-desktop",
"version": "0.2.0",
"private": true,
"type": "module",
"description": "PenguinHarness desktop app: Electron shell running @prismshadow/penguin-server as a utilityProcess, window on http://localhost (same-origin HTTP/SSE, no private IPC).",
"main": "dist/main.js",
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests",
"start": "electron ."
},
"dependencies": {
"@prismshadow/penguin-core": "workspace:*",
"@prismshadow/penguin-server": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.0.0",
"electron": "^43.2.0",
"tsup": "^8.3.0",
"typescript": "^5.6.0",
"vitest": "^3.2.6"
}
}
+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);
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
appOriginFor,
desktopLoginUrl,
isAppUrl,
parsePortFile,
restartDelayMs,
} from "../src/util.js";
describe("parsePortFile", () => {
it("accepts a port with surrounding whitespace", () => {
expect(parsePortFile("17365\n")).toBe(17365);
expect(parsePortFile(" 80 ")).toBe(80);
});
it("rejects garbage, empty, zero, and out-of-range values", () => {
expect(parsePortFile("")).toBeNull();
expect(parsePortFile("abc")).toBeNull();
expect(parsePortFile("0")).toBeNull();
expect(parsePortFile("65536")).toBeNull();
expect(parsePortFile("12 34")).toBeNull();
});
});
describe("app origin and login URL", () => {
it("builds the localhost origin and the one-shot login URL", () => {
expect(appOriginFor(7364)).toBe("http://localhost:7364");
expect(desktopLoginUrl("http://localhost:7364", "a b/c")).toBe(
"http://localhost:7364/api/auth/desktop-login?token=a%20b%2Fc",
);
});
});
describe("isAppUrl", () => {
const origin = "http://localhost:7364";
it("accepts only the app origin", () => {
expect(isAppUrl("http://localhost:7364/chat", origin)).toBe(true);
expect(isAppUrl("http://localhost:7365/", origin)).toBe(false);
expect(isAppUrl("http://127.0.0.1:7364/preview/x", origin)).toBe(false);
expect(isAppUrl("https://example.com", origin)).toBe(false);
expect(isAppUrl("not a url", origin)).toBe(false);
expect(isAppUrl("http://localhost:7364/", null)).toBe(false);
});
});
describe("restartDelayMs", () => {
it("doubles from 1s and caps at 8s", () => {
expect([0, 1, 2, 3, 4].map(restartDelayMs)).toEqual([1000, 2000, 4000, 8000, 8000]);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["src", "test"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/main.ts"],
format: ["esm"],
target: "node22",
platform: "node",
clean: true,
sourcemap: true,
// `electron` is a runtime builtin inside the Electron main process; the workspace
// packages stay external so the server entry keeps its own file identity (the shell
// forks it as a child by path) and lock.js resolves from node_modules.
external: ["electron", "@prismshadow/penguin-server", "@prismshadow/penguin-core"],
});
+4
View File
@@ -17,6 +17,10 @@
"./api": { "./api": {
"types": "./dist/api/types.d.ts", "types": "./dist/api/types.d.ts",
"import": "./dist/api/types.js" "import": "./dist/api/types.js"
},
"./lock": {
"types": "./dist/lock.d.ts",
"import": "./dist/lock.js"
} }
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
+16 -1
View File
@@ -72,10 +72,25 @@ export interface MeResponse {
* request, since it depends on the host the caller is using. * request, since it depends on the host the caller is using.
*/ */
previewIsolated: boolean; previewIsolated: boolean;
/**
* Whether this server runs in desktop mode (spawned by the desktop shell with
* PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the
* initial-password banner and the self-update entry, and omits the old-password
* field when changing the password. See design § "桌面端原型".
*/
desktopMode: boolean;
/**
* How THIS session was established. Distinct from desktopMode: a browser signed into a
* desktop-mode server holds a "password" session and must still provide the old
* password when changing it — only "desktop" sessions (opened by the shell's one-shot
* token) may omit it.
*/
sessionVia: "password" | "desktop";
} }
export interface PasswordChangeRequest { export interface PasswordChangeRequest {
oldPassword: string; /** Omitted only by desktop-established sessions (desktop mode); required otherwise. */
oldPassword?: string;
/** At least 8 characters. */ /** At least 8 characters. */
newPassword: string; newPassword: string;
} }
+10
View File
@@ -60,6 +60,8 @@ import { TitleGenerator } from "./runtime/title-generator.js";
import type { TitleNotifier } from "./runtime/title-generator.js"; import type { TitleNotifier } from "./runtime/title-generator.js";
import { UsageRecorder } from "./runtime/usage-recorder.js"; import { UsageRecorder } from "./runtime/usage-recorder.js";
import { AdminService } from "./services/admin-service.js"; import { AdminService } from "./services/admin-service.js";
import { DesktopService } from "./services/desktop-service.js";
import { desktopRoutes } from "./http/routes/desktop.js";
import { AgentConfigService } from "./services/agent-config-service.js"; import { AgentConfigService } from "./services/agent-config-service.js";
import { AgentService } from "./services/agent-service.js"; import { AgentService } from "./services/agent-service.js";
import { BenchmarkService } from "./services/benchmark-service.js"; import { BenchmarkService } from "./services/benchmark-service.js";
@@ -114,6 +116,8 @@ export interface AppDeps {
sessionSources: SessionSources; sessionSources: SessionSources;
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */ /** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
errors: ErrorRecorder; errors: ErrorRecorder;
/** Desktop mode (PENGUIN_DESKTOP_TOKEN): one-shot login + shutdown token holder; null outside desktop mode. */
desktop: DesktopService | null;
/** Request log output (minimal one-liner); tests inject a noop. */ /** Request log output (minimal one-liner); tests inject a noop. */
log: (line: string) => void; log: (line: string) => void;
} }
@@ -276,6 +280,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
manager, manager,
sessionSources, sessionSources,
errors, errors,
desktop: config.desktopToken !== null ? new DesktopService(config.desktopToken) : null,
log, log,
}; };
} }
@@ -355,6 +360,11 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
// Public routes (no login required). // Public routes (no login required).
app.route("/api/auth", authRoutes(deps)); app.route("/api/auth", authRoutes(deps));
// Desktop shutdown authenticates with the shell's Bearer token, not the cookie
// session, so it mounts outside authMiddleware (and only in desktop mode).
if (deps.desktop) {
app.route("/api/desktop", desktopRoutes(deps));
}
// Protected routes: cookie -> auth_session -> user. // Protected routes: cookie -> auth_session -> user.
const auth = authMiddleware(deps.authService); const auth = authMiddleware(deps.authService);
+7 -4
View File
@@ -11,7 +11,7 @@ import type { MiddlewareHandler } from "hono";
import { getCookie } from "hono/cookie"; import { getCookie } from "hono/cookie";
import { HttpError } from "../http/errors.js"; import { HttpError } from "../http/errors.js";
import type { UserRow } from "../db/repos/users.js"; import type { UserRow } from "../db/repos/users.js";
import type { AuthService } from "./service.js"; import type { AuthService, SessionVia } from "./service.js";
/** Session cookie name. */ /** Session cookie name. */
export const SESSION_COOKIE = "penguin_session"; export const SESSION_COOKIE = "penguin_session";
@@ -20,6 +20,8 @@ export const SESSION_COOKIE = "penguin_session";
export type AppEnv = { export type AppEnv = {
Variables: { Variables: {
user: UserRow; user: UserRow;
/** How the current session was established ("password" | "desktop"); legacy rows read as "password". */
sessionVia: SessionVia;
}; };
}; };
@@ -31,11 +33,12 @@ export function currentUser(c: { var: { user: UserRow } }): UserRow {
export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> { export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
return async (c, next) => { return async (c, next) => {
const token = getCookie(c, SESSION_COOKIE); const token = getCookie(c, SESSION_COOKIE);
const user = token ? auth.authenticate(token) : null; const authed = token ? auth.authenticateWithMeta(token) : null;
if (!user) { if (!authed) {
throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired."); throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired.");
} }
c.set("user", user); c.set("user", authed.user);
c.set("sessionVia", authed.via);
await next(); await next();
}; };
} }
+43 -4
View File
@@ -57,6 +57,14 @@ function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex"); return createHash("sha256").update(value).digest("hex");
} }
/**
* How a session was established: "password" via the login form, "desktop" via the
* desktop shell's one-shot token (see design § "桌面端原型 · 桌面登录"). Persisted per
* session so desktop-specific allowances (password change without the old password)
* apply only to sessions the shell itself opened. Legacy rows (NULL) read as "password".
*/
export type SessionVia = "password" | "desktop";
export function toUserInfo(row: UserRow): UserInfo { export function toUserInfo(row: UserRow): UserInfo {
return { return {
userId: row.userId, userId: row.userId,
@@ -159,7 +167,22 @@ export class AuthService {
} }
this.loginFailures.delete(userId); this.loginFailures.delete(userId);
this.deps.authSessions.deleteExpired(this.now().toISOString()); this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId) }; return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
}
/**
* Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
* (the desktop-login route) has already redeemed the shell's one-shot token, which is
* the credential here. Throws if the admin has not been seeded yet (desktop-login runs
* after startup seeding, so this only trips on a broken deployment).
*/
loginDesktop(): { user: UserInfo; token: string } {
const row = this.deps.users.findById(ADMIN_USER_ID);
if (!row) {
throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
}
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
} }
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */ /** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
@@ -174,12 +197,25 @@ export class AuthService {
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false); this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
} }
/**
* Desktop-session password set: no old-password check. Only reachable for sessions
* established via desktop-login (the me route gates on sessionVia) — the seed password
* of a desktop-created root is random and never shown, so its holder has nothing to
* type into an old-password field; the shell's token already proved machine ownership.
*/
async setPasswordDesktop(userId: string, newPassword: string): Promise<void> {
if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
}
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
}
logout(token: string): void { logout(token: string): void {
this.deps.authSessions.delete(sha256Hex(token)); this.deps.authSessions.delete(sha256Hex(token));
} }
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */ /** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
authenticate(token: string): UserRow | null { authenticateWithMeta(token: string): { user: UserRow; via: SessionVia } | null {
const tokenHash = sha256Hex(token); const tokenHash = sha256Hex(token);
const session = this.deps.authSessions.findByTokenHash(tokenHash); const session = this.deps.authSessions.findByTokenHash(tokenHash);
if (!session) return null; if (!session) return null;
@@ -195,10 +231,12 @@ export class AuthService {
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(), new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
); );
} }
return this.deps.users.findById(session.userId); const user = this.deps.users.findById(session.userId);
if (!user) return null;
return { user, via: session.via === "desktop" ? "desktop" : "password" };
} }
private issueSession(userId: string): string { private issueSession(userId: string, via: SessionVia): string {
const token = randomBytes(32).toString("base64url"); const token = randomBytes(32).toString("base64url");
const now = this.now(); const now = this.now();
this.deps.authSessions.insert({ this.deps.authSessions.insert({
@@ -206,6 +244,7 @@ export class AuthService {
userId, userId,
createdAt: now.toISOString(), createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(), expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
via,
}); });
return token; return token;
} }
+35 -5
View File
@@ -9,6 +9,7 @@
* detected to exist. * detected to exist.
* Docs: /docs/configuration § "Environment variables". * Docs: /docs/configuration § "Environment variables".
*/ */
import { randomBytes } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -35,13 +36,29 @@ export interface ServerConfig {
/** /**
* Fixed initial password for the seeded built-in admin (PENGUIN_SEED_ADMIN_PASSWORD), * Fixed initial password for the seeded built-in admin (PENGUIN_SEED_ADMIN_PASSWORD),
* used by automated tests and e2e; null (the norm) makes the seed generate a random * used by automated tests and e2e; null (the norm) makes the seed generate a random
* `penguin-<4 digits>` password, printed once to the server console. * `penguin-<4 digits>` password, printed once to the server console. In desktop mode
* an unpinned value resolves to a FULLY random password instead (never printed):
* sign-in there goes through the shell's one-shot token, so nobody needs to read the
* seed. See design § "桌面端原型 · 桌面登录".
*/ */
seedAdminPassword: string | null; seedAdminPassword: string | null;
/** Login session validity period (7 days). */ /** Login session validity period (7 days). */
authSessionTtlMs: number; authSessionTtlMs: number;
/** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */ /** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */
authSessionRenewMs: number; authSessionRenewMs: number;
/**
* Desktop mode (PENGUIN_DESKTOP_TOKEN): the per-launch token minted by the desktop
* shell. Non-null enables the one-shot desktop-login and Bearer-token shutdown
* endpoints and requires a loopback HOST — desktop mode passes the token through a
* URL, which must never leave the machine. See design § "桌面端原型".
*/
desktopToken: string | null;
/**
* Port announcement file (PENGUIN_PORT_FILE): after the listener is up, the actual
* bound port is written here — the supervising process's way to learn the port when
* it starts the server with PORT=0.
*/
portFile: string | null;
} }
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
@@ -79,7 +96,7 @@ function normalizePreviewOrigin(raw: string | undefined): string | null {
return url.origin; return url.origin;
} }
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD). */ /** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD / PENGUIN_DESKTOP_TOKEN / PENGUIN_PORT_FILE). */
export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const root = env.PENGUIN_HOME ?? resolveRoot(); const root = env.PENGUIN_HOME ?? resolveRoot();
// An empty PORT string is treated as unset (the common `.env` case of an empty // An empty PORT string is treated as unset (the common `.env` case of an empty
@@ -89,16 +106,29 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
if (!Number.isInteger(port) || port < 0 || port > 65535) { if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`Invalid port configuration PORT=${env.PORT}`); throw new Error(`Invalid port configuration PORT=${env.PORT}`);
} }
const host = env.HOST ?? "127.0.0.1";
const desktopToken = env.PENGUIN_DESKTOP_TOKEN?.trim() || null;
// Desktop mode redeems its token through a URL: never allow it off loopback.
if (desktopToken !== null && host !== "127.0.0.1" && host !== "localhost") {
throw new Error(`Desktop mode requires a loopback HOST (got HOST=${host})`);
}
return { return {
root, root,
host: env.HOST ?? "127.0.0.1", host,
port, port,
dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"), dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"),
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(), webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN), previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
// An empty/whitespace value is treated as unset (→ random seed password). // An empty/whitespace value is treated as unset (→ random seed password). Desktop
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || null, // mode without a pinned value seeds a FULLY random password rather than the
// printable penguin-<4 digits>: desktop sign-in goes through the shell's token, so
// the seed never needs to be read — and index.ts deliberately does not print it.
seedAdminPassword:
env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() ||
(desktopToken !== null ? randomBytes(24).toString("base64url") : null),
authSessionTtlMs: 7 * DAY_MS, authSessionTtlMs: 7 * DAY_MS,
authSessionRenewMs: 6 * DAY_MS, authSessionRenewMs: 6 * DAY_MS,
desktopToken,
portFile: env.PENGUIN_PORT_FILE?.trim() || null,
}; };
} }
+1
View File
@@ -29,6 +29,7 @@ export function openDatabase(dbPath: string): DatabaseSync {
// schema.ts; drop entries only in a release allowed to break existing web.db files. // schema.ts; drop entries only in a release allowed to break existing web.db files.
ensureColumn(db, "sessions", "client", "TEXT"); ensureColumn(db, "sessions", "client", "TEXT");
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0"); ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "auth_sessions", "via", "TEXT");
return db; return db;
} }
@@ -10,6 +10,8 @@ export interface AuthSessionRow {
userId: string; userId: string;
createdAt: string; createdAt: string;
expiresAt: string; expiresAt: string;
/** How the session was established ("password" | "desktop"); null on rows formed before the column existed (treated as "password"). */
via: string | null;
} }
export class AuthSessionsRepo { export class AuthSessionsRepo {
@@ -18,9 +20,9 @@ export class AuthSessionsRepo {
insert(row: AuthSessionRow): void { insert(row: AuthSessionRow): void {
this.db this.db
.prepare( .prepare(
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", "INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)",
) )
.run(row.tokenHash, row.userId, row.createdAt, row.expiresAt); .run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
} }
findByTokenHash(tokenHash: string): AuthSessionRow | null { findByTokenHash(tokenHash: string): AuthSessionRow | null {
@@ -31,6 +33,7 @@ export class AuthSessionsRepo {
userId: r.user_id as string, userId: r.user_id as string,
createdAt: r.created_at as string, createdAt: r.created_at as string,
expiresAt: r.expires_at as string, expiresAt: r.expires_at as string,
via: (r.via as string | null) ?? null,
}; };
} }
+2 -1
View File
@@ -22,7 +22,8 @@ CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
expires_at TEXT NOT NULL -- 7-day sliding renewal (topped up when <6 days remain) expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain)
via TEXT -- 'password' | 'desktop'; NULL = legacy row (password)
); );
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
+19 -1
View File
@@ -1,11 +1,12 @@
/** /**
* Auth routes: POST /api/auth/login | logout. * Auth routes: POST /api/auth/login | logout, GET /api/auth/desktop-login (desktop mode).
* No self-registration: users are created by an admin in the user backend (/api/admin/users). * No self-registration: users are created by an admin in the user backend (/api/admin/users).
* Login issues a cookie session; logout deletes the server-side session and clears the cookie. * Login issues a cookie session; logout deletes the server-side session and clears the cookie.
*/ */
import { Hono } from "hono"; import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import type { AuthResponse } from "../../api/types.js"; import type { AuthResponse } from "../../api/types.js";
import { HttpError } from "../errors.js";
import { SESSION_COOKIE } from "../../auth/middleware.js"; import { SESSION_COOKIE } from "../../auth/middleware.js";
import type { AppEnv } from "../../auth/middleware.js"; import type { AppEnv } from "../../auth/middleware.js";
import { readJson, requireString } from "../validate.js"; import { readJson, requireString } from "../validate.js";
@@ -42,5 +43,22 @@ export function authRoutes(deps: AppDeps): Hono<AppEnv> {
return c.body(null, 204); return c.body(null, 204);
}); });
// Desktop-mode sign-in: the window's FIRST navigation redeems the shell's one-shot
// token for a standard admin cookie session and lands on the app — the desktop user
// never sees the login page. 404 outside desktop mode (the route "doesn't exist");
// a wrong or already-used token is a plain 401 with no distinction, so a leaked URL
// reveals nothing and cannot be replayed. See design § "桌面端原型 · 桌面登录".
app.get("/desktop-login", (c) => {
const desktop = deps.desktop;
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
const token = c.req.query("token") ?? "";
if (token === "" || !desktop.redeemLoginToken(token)) {
throw new HttpError(401, "unauthorized", "Invalid or already-used desktop token.");
}
const { token: session } = deps.authService.loginDesktop();
setCookie(c, SESSION_COOKIE, session, cookieOptions(c));
return c.redirect("/", 302);
});
return app; return app;
} }
@@ -0,0 +1,32 @@
/**
* Desktop-mode routes: POST /api/desktop/shutdown.
*
* Authenticated by the shell's Bearer token, not the cookie session (the shell holds no
* cookie), so this mounts OUTSIDE authMiddleware and only when desktop mode is enabled.
* Responds 202 first, then triggers the graceful shutdown a beat later so the response
* isn't cut off by the closing listener.
*/
import { Hono } from "hono";
import { HttpError } from "../errors.js";
import type { AppDeps } from "../../app.js";
/** Delay between answering 202 and starting shutdown: lets the response flush. */
const SHUTDOWN_DELAY_MS = 50;
export function desktopRoutes(deps: AppDeps): Hono {
const app = new Hono();
app.post("/shutdown", (c) => {
const desktop = deps.desktop;
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
const header = c.req.header("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
if (token === "" || !desktop.verifyToken(token)) {
throw new HttpError(401, "unauthorized", "Invalid desktop token.");
}
setTimeout(() => desktop.requestShutdown(), SHUTDOWN_DELAY_MS).unref();
return c.body(null, 202);
});
return app;
}
+12 -2
View File
@@ -28,15 +28,25 @@ export function meRoutes(deps: AppDeps): Hono<AppEnv> {
return c.json({ return c.json({
user: toUserInfo(c.var.user), user: toUserInfo(c.var.user),
previewIsolated: target !== null, previewIsolated: target !== null,
desktopMode: deps.desktop !== null,
sessionVia: c.var.sessionVia,
} satisfies MeResponse); } satisfies MeResponse);
}); });
// Self-service password change (user settings): validates the old password; on success, the initial-password prompt disappears from GET /api/me. // Self-service password change (user settings): validates the old password; on success, the initial-password prompt disappears from GET /api/me.
// Desktop sessions may omit oldPassword: the seed password of a desktop-created root is
// random and never shown, so its holder has nothing to type — the shell's redeemed
// token already proved machine ownership (see design § "桌面端原型 · 桌面登录").
app.put("/password", async (c) => { app.put("/password", async (c) => {
const body = await readJson(c); const body = await readJson(c);
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
const newPassword = requireString(body, "newPassword", { label: "newPassword" }); const newPassword = requireString(body, "newPassword", { label: "newPassword" });
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword); const desktopSession = deps.desktop !== null && c.var.sessionVia === "desktop";
if (desktopSession && body.oldPassword === undefined) {
await deps.authService.setPasswordDesktop(c.var.user.userId, newPassword);
} else {
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
}
return c.body(null, 204); return c.body(null, 204);
}); });
+81 -15
View File
@@ -9,15 +9,35 @@
* persist + log, with the fatal one still shutting down per existing semantics (see the * persist + log, with the fatal one still shutting down per existing semantics (see the
* comment below). * comment below).
*/ */
import fs from "node:fs";
import path from "node:path";
import { config as loadDotenv } from "dotenv"; import { config as loadDotenv } from "dotenv";
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { buildAppDeps, createApp } from "./app.js"; import { buildAppDeps, createApp } from "./app.js";
import { resolveServerConfig } from "./config.js"; import { resolveServerConfig } from "./config.js";
import { loopbackHostRoles } from "./services/preview-token.js"; import { loopbackHostRoles } from "./services/preview-token.js";
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js";
loadDotenv({ quiet: true }); loadDotenv({ quiet: true });
/** Exit code for "another server already owns this data root" (see lock.ts). */
const EXIT_ALREADY_RUNNING = 3;
const config = resolveServerConfig(); const config = resolveServerConfig();
// Single instance per data root: web.db is single-writer and the scheduler must not run
// twice, so refuse to start when a live server already owns this root — BEFORE opening
// the database. The CLI and the desktop shell pre-check the same lock for a friendlier
// path (open / attach to the existing instance); this is the in-process backstop.
const existingLock = await liveServerLock(config.root);
if (existingLock) {
console.error(
`Another PenguinHarness server is already running on this data root (pid ${existingLock.pid}).`,
);
console.error(`Existing instance: http://localhost:${existingLock.port}/`);
process.exit(EXIT_ALREADY_RUNNING);
}
const deps = buildAppDeps(config); const deps = buildAppDeps(config);
const app = createApp(deps); const app = createApp(deps);
@@ -25,7 +45,10 @@ const app = createApp(deps);
// users table is empty. The returned initial password (random unless pinned via // users table is empty. The returned initial password (random unless pinned via
// PENGUIN_SEED_ADMIN_PASSWORD) is printed here once — the only place it is ever shown. // PENGUIN_SEED_ADMIN_PASSWORD) is printed here once — the only place it is ever shown.
const seededAdminPassword = await deps.authService.seedAdmin(); const seededAdminPassword = await deps.authService.seedAdmin();
if (seededAdminPassword !== null) { // Never printed in desktop mode: the seed there is fully random by design (config.ts)
// and sign-in goes through the shell's one-shot token, so showing it would only leak a
// credential into a log nobody needs.
if (seededAdminPassword !== null && config.desktopToken === null) {
console.log( console.log(
`Seeded built-in admin "admin" — initial password: ${seededAdminPassword} (change it after first sign-in)`, `Seeded built-in admin "admin" — initial password: ${seededAdminPassword} (change it after first sign-in)`,
); );
@@ -44,11 +67,6 @@ deps.goalsRepo.abortOrphanedActive();
// counterpart is reserved for previews, so advertise the canonical name — the other one // counterpart is reserved for previews, so advertise the canonical name — the other one
// only 302s back here for App routes (see the canonical-host guard in app.ts). // only 302s back here for App routes (see the canonical-host guard in app.ts).
const appHost = loopbackHostRoles(config.host)?.app ?? config.host; const appHost = loopbackHostRoles(config.host)?.app ?? config.host;
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`penguin-server started: http://${appHost}:${info.port}`);
console.log(`Data root: ${config.root}`);
console.log(`SQLite: ${config.dbPath}`);
});
/** /**
* Second loopback listener so the preview origin is actually reachable. * Second loopback listener so the preview origin is actually reachable.
@@ -58,17 +76,56 @@ const server = serve({ fetch: app.fetch, hostname: config.host, port: config.por
* systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1` * systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1`
* would leave every preview URL refusing connections. Binding `::1` as well closes that * would leave every preview URL refusing connections. Binding `::1` as well closes that
* gap. Failure is non-fatal — the App keeps working, previews just fall back. * gap. Failure is non-fatal — the App keeps working, previews just fall back.
*
* Created inside the main listener's callback so it reuses the ACTUAL bound port: with
* PORT=0 both listeners resolving 0 independently would land on two different ports and
* every preview URL (same port, counterpart host) would refuse connections.
*/ */
const ipv6Loopback = let ipv6Loopback: ReturnType<typeof serve> | null = null;
config.host === "127.0.0.1" || config.host === "localhost"
? serve({ fetch: app.fetch, hostname: "::1", port: config.port }) /** Port announcement (PENGUIN_PORT_FILE): tmp + rename, so a polling reader never sees a partial write. */
: null; function writePortFile(file: string, port: number): void {
ipv6Loopback?.on("error", (err: NodeJS.ErrnoException) => { fs.mkdirSync(path.dirname(file), { recursive: true });
console.warn( const tmp = `${file}.${process.pid}.tmp`;
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`, fs.writeFileSync(tmp, `${port}\n`);
); fs.renameSync(tmp, file);
}
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`penguin-server started: http://${appHost}:${info.port}`);
console.log(`Data root: ${config.root}`);
console.log(`SQLite: ${config.dbPath}`);
if (config.desktopToken !== null) console.log("Desktop mode: enabled");
// The root exists by now (openDatabase created it), and the pre-start check found no
// live owner — record ourselves as this root's server.
acquireServerLock(config.root, {
pid: process.pid,
port: info.port,
startedAt: new Date().toISOString(),
});
if (config.portFile !== null) writePortFile(config.portFile, info.port);
if (config.host === "127.0.0.1" || config.host === "localhost") {
ipv6Loopback = serve({ fetch: app.fetch, hostname: "::1", port: info.port });
ipv6Loopback.on("error", (err: NodeJS.ErrnoException) => {
console.warn(
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
);
});
}
}); });
/** Removes the instance lock and port file (best-effort; runs on both exit paths). */
function cleanupInstanceFiles(): void {
releaseServerLock(config.root);
if (config.portFile !== null) {
try {
fs.rmSync(config.portFile, { force: true });
} catch {
// Best-effort: a stale port file is rewritten by the next server.
}
}
}
let shuttingDown = false; let shuttingDown = false;
async function shutdown(signal: string, exitCode = 0): Promise<void> { async function shutdown(signal: string, exitCode = 0): Promise<void> {
if (shuttingDown) return; if (shuttingDown) return;
@@ -80,15 +137,24 @@ async function shutdown(signal: string, exitCode = 0): Promise<void> {
ipv6Loopback?.close(); ipv6Loopback?.close();
server.close(() => { server.close(() => {
deps.db.close(); deps.db.close();
cleanupInstanceFiles();
process.exit(exitCode); process.exit(exitCode);
}); });
// Fallback: a long-lived SSE connection may block the close callback, so force exit after 1s. // Fallback: a long-lived SSE connection may block the close callback, so force exit after 1s.
setTimeout(() => process.exit(exitCode), 1000).unref(); setTimeout(() => {
cleanupInstanceFiles();
process.exit(exitCode);
}, 1000).unref();
} }
process.on("SIGINT", () => void shutdown("SIGINT")); process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM")); process.on("SIGTERM", () => void shutdown("SIGTERM"));
// Desktop shell quit path: POST /api/desktop/shutdown lands here — the same graceful
// shutdown as the signals, reachable over HTTP because a Windows child kill is a hard
// TerminateProcess with no signal delivery.
deps.desktop?.onShutdownRequest(() => void shutdown("desktop-shutdown"));
// Process-level error fallback: once a background // Process-level error fallback: once a background
// fire-and-forget promise (title generation, Session drive, etc.) throws, the error // fire-and-forget promise (title generation, Session drive, etc.) throws, the error
// reaches the process without passing through any catch — persist it first for a // reaches the process without passing through any catch — persist it first for a
+110
View File
@@ -0,0 +1,110 @@
/**
* Root-level server instance lock (`<root>/server.lock`).
*
* web.db is single-process / single-writer (see db/database.ts), and two servers on one
* data root would also double-run the schedule scheduler — so a data root admits one
* server at a time. The lock records {pid, port, startedAt}; liveness requires BOTH the
* pid to be alive AND the recorded port to accept a TCP connection, because either signal
* alone false-positives (pids get recycled, ports get taken by unrelated processes). A
* lock that fails the liveness check is stale and is simply overwritten by the next
* server.
*
* Published as `@prismshadow/penguin-server/lock` (side-effect-free) so the CLI and the
* desktop shell can pre-check a root without importing the package entry, which starts
* listening. Docs: design § "桌面端原型 · 数据根与实例互斥".
*/
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
export interface ServerLock {
pid: number;
port: number;
startedAt: string;
}
/** TCP probe budget: loopback either connects immediately or the port is dead. */
const PROBE_TIMEOUT_MS = 500;
export function serverLockPath(root: string): string {
return path.join(root, "server.lock");
}
/** Reads the lock file; a missing or malformed file reads as "no lock". */
export function readServerLock(root: string): ServerLock | null {
let raw: string;
try {
raw = fs.readFileSync(serverLockPath(root), "utf8");
} catch {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<ServerLock>;
if (
typeof parsed.pid !== "number" ||
!Number.isInteger(parsed.pid) ||
typeof parsed.port !== "number" ||
!Number.isInteger(parsed.port)
) {
return null;
}
return { pid: parsed.pid, port: parsed.port, startedAt: String(parsed.startedAt ?? "") };
} catch {
return null;
}
}
function pidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM = the process exists but belongs to another user — still alive.
return (err as NodeJS.ErrnoException).code === "EPERM";
}
}
function portAccepts(port: number): Promise<boolean> {
return new Promise((resolve) => {
// 127.0.0.1 rather than localhost: this is a raw TCP liveness probe, not an App
// request, and the server binds 127.0.0.1 (plus ::1) on loopback setups.
const socket = net.connect({ host: "127.0.0.1", port, timeout: PROBE_TIMEOUT_MS });
const done = (ok: boolean) => {
socket.destroy();
resolve(ok);
};
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
});
}
/** True when the lock's process is alive AND its port accepts connections. */
export async function isServerLockAlive(lock: ServerLock): Promise<boolean> {
return pidAlive(lock.pid) && (await portAccepts(lock.port));
}
/** Convenience for pre-checks: the live lock on this root, or null (absent or stale). */
export async function liveServerLock(root: string): Promise<ServerLock | null> {
const lock = readServerLock(root);
if (!lock) return null;
return (await isServerLockAlive(lock)) ? lock : null;
}
/** Writes the lock atomically (tmp + rename; the parent directory must already exist). */
export function acquireServerLock(root: string, lock: ServerLock): void {
const target = serverLockPath(root);
const tmp = `${target}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(lock) + "\n");
fs.renameSync(tmp, target);
}
/** Removes the lock if it is still ours (best-effort; never throws on shutdown paths). */
export function releaseServerLock(root: string): void {
try {
const lock = readServerLock(root);
if (lock && lock.pid === process.pid) fs.rmSync(serverLockPath(root));
} catch {
// Best-effort: a stale leftover is overwritten by the next server anyway.
}
}
@@ -0,0 +1,55 @@
/**
* Desktop mode (PENGUIN_DESKTOP_TOKEN): the shell that spawned this server proves itself
* with a per-launch random token, which backs two endpoints with different consumption
* rules:
*
* - `GET /api/auth/desktop-login?token=…` — ONE-SHOT: the window's first navigation
* redeems the token for a standard admin cookie session; every later attempt fails,
* so a leaked URL cannot be replayed.
* - `POST /api/desktop/shutdown` (Authorization: Bearer <token>) — REUSABLE for the
* process lifetime: the token here identifies the supervising shell, which may need
* the endpoint at any point (POSIX quit, and the only graceful path on Windows,
* where killing a child is a hard TerminateProcess).
*
* Comparisons hash both sides first so timingSafeEqual gets equal-length buffers.
* Docs: design § "桌面端原型 · 桌面登录".
*/
import { createHash, timingSafeEqual } from "node:crypto";
function digest(value: string): Buffer {
return createHash("sha256").update(value).digest();
}
export class DesktopService {
private readonly tokenDigest: Buffer;
private loginConsumed = false;
private shutdownHandler: (() => void) | null = null;
constructor(token: string) {
this.tokenDigest = digest(token);
}
/** Constant-time token check (no consumption). */
verifyToken(candidate: string): boolean {
return timingSafeEqual(digest(candidate), this.tokenDigest);
}
/** One-shot login redemption: true exactly once, for the correct token. */
redeemLoginToken(candidate: string): boolean {
if (this.loginConsumed || !this.verifyToken(candidate)) return false;
this.loginConsumed = true;
return true;
}
/** index.ts registers the actual graceful-shutdown trigger after assembly. */
onShutdownRequest(handler: () => void): void {
this.shutdownHandler = handler;
}
/** Invoked by the shutdown route; false when no handler is registered (tests). */
requestShutdown(): boolean {
if (!this.shutdownHandler) return false;
this.shutdownHandler();
return true;
}
}
+26
View File
@@ -31,6 +31,32 @@ describe("resolveServerConfig: PORT parsing", () => {
}); });
}); });
describe("resolveServerConfig: desktop-mode seed password", () => {
it("desktop mode without a pinned value generates a fully random seed password", () => {
const a = resolveServerConfig({ ...base, PENGUIN_DESKTOP_TOKEN: "tok" }).seedAdminPassword;
const b = resolveServerConfig({ ...base, PENGUIN_DESKTOP_TOKEN: "tok" }).seedAdminPassword;
expect(a).not.toBeNull();
// base64url of 24 random bytes: far beyond the printable penguin-<4 digits> space.
expect(a!.length).toBeGreaterThanOrEqual(24);
expect(a).not.toMatch(/^penguin-\d{4}$/);
expect(a).not.toBe(b);
});
it("an explicit PENGUIN_SEED_ADMIN_PASSWORD still wins in desktop mode", () => {
expect(
resolveServerConfig({
...base,
PENGUIN_DESKTOP_TOKEN: "tok",
PENGUIN_SEED_ADMIN_PASSWORD: "penguin-2026",
}).seedAdminPassword,
).toBe("penguin-2026");
});
it("outside desktop mode the unpinned value stays null (random penguin-<4 digits> at seed time)", () => {
expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull();
});
});
describe("resolveServerConfig: PENGUIN_SEED_ADMIN_PASSWORD parsing", () => { describe("resolveServerConfig: PENGUIN_SEED_ADMIN_PASSWORD parsing", () => {
it("unset/empty/whitespace → null; a value is kept trimmed", () => { it("unset/empty/whitespace → null; a value is kept trimmed", () => {
expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull(); expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull();
+170
View File
@@ -0,0 +1,170 @@
/**
* Desktop mode: one-shot desktop-login, Bearer-token shutdown, desktopMode in /api/me,
* and the desktop-session password change without oldPassword.
*/
import { describe, expect, it } from "vitest";
import { apiClient, createTestApp, loginAdmin } from "./helpers.js";
import type { MeResponse } from "../src/api/types.js";
const TOKEN = "test-desktop-token";
function desktopApp() {
return createTestApp({ config: { desktopToken: TOKEN } });
}
describe("desktop-login", () => {
it("redeems the token once: cookie session, redirect to /, second attempt 401", async () => {
const t = await desktopApp();
try {
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/");
const cookie = res.headers.get("set-cookie");
expect(cookie).toContain("penguin_session=");
const me = await t.app.request("/api/me", {
headers: { cookie: cookie!.split(";")[0]! },
});
expect(me.status).toBe(200);
const body = (await me.json()) as MeResponse;
expect(body.user.userId).toBe("admin");
expect(body.desktopMode).toBe(true);
const replay = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
expect(replay.status).toBe(401);
} finally {
await t.cleanup();
}
});
it("rejects a wrong or missing token without consuming the real one", async () => {
const t = await desktopApp();
try {
expect((await t.app.request("/api/auth/desktop-login?token=wrong")).status).toBe(401);
expect((await t.app.request("/api/auth/desktop-login")).status).toBe(401);
// The real token still works after failed attempts.
expect((await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`)).status).toBe(302);
} finally {
await t.cleanup();
}
});
it("is 404 outside desktop mode, and /api/me reports desktopMode false", async () => {
const t = await createTestApp();
try {
expect((await t.app.request("/api/auth/desktop-login?token=x")).status).toBe(404);
const admin = await loginAdmin(t.app);
const me = await apiClient(t.app, admin.cookie).get("/api/me");
expect(((await me.json()) as MeResponse).desktopMode).toBe(false);
} finally {
await t.cleanup();
}
});
});
describe("desktop shutdown endpoint", () => {
it("accepts the Bearer token repeatedly and triggers the registered handler", async () => {
const t = await desktopApp();
try {
let requested = 0;
t.deps.desktop!.onShutdownRequest(() => {
requested += 1;
});
const res = await t.app.request("/api/desktop/shutdown", {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(res.status).toBe(202);
// The route defers the trigger so the 202 can flush first.
await new Promise((r) => setTimeout(r, 80));
expect(requested).toBe(1);
// Unlike the login token, the shutdown credential is NOT one-shot.
const again = await t.app.request("/api/desktop/shutdown", {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(again.status).toBe(202);
} finally {
await t.cleanup();
}
});
it("rejects wrong or missing tokens, and does not exist outside desktop mode", async () => {
const t = await desktopApp();
try {
const wrong = await t.app.request("/api/desktop/shutdown", {
method: "POST",
headers: { authorization: "Bearer nope" },
});
expect(wrong.status).toBe(401);
const missing = await t.app.request("/api/desktop/shutdown", { method: "POST" });
expect(missing.status).toBe(401);
} finally {
await t.cleanup();
}
const plain = await createTestApp();
try {
// Outside desktop mode the route is not mounted; the request falls through to the
// cookie auth middleware, which rejects the cookieless caller with 401.
const res = await plain.app.request("/api/desktop/shutdown", {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(res.status).toBe(401);
} finally {
await plain.cleanup();
}
});
});
describe("desktop-session password change", () => {
async function desktopCookie(t: Awaited<ReturnType<typeof desktopApp>>): Promise<string> {
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
return res.headers.get("set-cookie")!.split(";")[0]!;
}
it("allows omitting oldPassword for a desktop session and clears the initial flag", async () => {
const t = await desktopApp();
try {
const cookie = await desktopCookie(t);
const res = await apiClient(t.app, cookie).put("/api/me/password", {
newPassword: "brand-new-password",
});
expect(res.status).toBe(204);
const me = (await (await apiClient(t.app, cookie).get("/api/me")).json()) as MeResponse;
expect(me.user.passwordIsInitial).toBe(false);
} finally {
await t.cleanup();
}
});
it("still validates oldPassword when it is provided by a desktop session", async () => {
const t = await desktopApp();
try {
const cookie = await desktopCookie(t);
const res = await apiClient(t.app, cookie).put("/api/me/password", {
oldPassword: "wrong-password",
newPassword: "brand-new-password",
});
expect(res.status).toBe(400);
} finally {
await t.cleanup();
}
});
it("keeps requiring oldPassword for password-established sessions in desktop mode", async () => {
const t = await desktopApp();
try {
// Sign in via the regular login form against the same desktop-mode server.
const admin = await loginAdmin(t.app);
const res = await apiClient(t.app, admin.cookie).put("/api/me/password", {
newPassword: "brand-new-password",
});
expect(res.status).toBe(400);
} finally {
await t.cleanup();
}
});
});
+2
View File
@@ -39,6 +39,8 @@ export function testConfig(root: string): ServerConfig {
seedAdminPassword: TEST_ADMIN_PASSWORD, seedAdminPassword: TEST_ADMIN_PASSWORD,
authSessionTtlMs: 7 * DAY_MS, authSessionTtlMs: 7 * DAY_MS,
authSessionRenewMs: 6 * DAY_MS, authSessionRenewMs: 6 * DAY_MS,
desktopToken: null,
portFile: null,
}; };
} }
+107
View File
@@ -0,0 +1,107 @@
/**
* Server instance lock: read/acquire/release round-trip, stale detection (dead pid,
* dead port), and the live path against a real loopback listener.
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import {
acquireServerLock,
isServerLockAlive,
liveServerLock,
readServerLock,
releaseServerLock,
serverLockPath,
} from "../src/lock.js";
import { makeTempRoot } from "./helpers.js";
/** A pid that is guaranteed dead: a just-exited child of ours. */
function deadPid(): number {
const child = spawnSync(process.execPath, ["-e", ""]);
return child.pid ?? 2 ** 21;
}
function listen(): Promise<{ port: number; close: () => Promise<void> }> {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(0, "127.0.0.1", () => {
const port = (srv.address() as net.AddressInfo).port;
resolve({
port,
close: () => new Promise((r) => srv.close(() => r())),
});
});
});
}
describe("server lock", () => {
const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) {
await fs.promises.rm(root, { recursive: true, force: true });
}
});
async function tempRoot(): Promise<string> {
const root = await makeTempRoot();
roots.push(root);
return root;
}
it("reads null on a missing or malformed file, and round-trips acquire/read", async () => {
const root = await tempRoot();
expect(readServerLock(root)).toBeNull();
fs.writeFileSync(serverLockPath(root), "not json");
expect(readServerLock(root)).toBeNull();
fs.writeFileSync(serverLockPath(root), JSON.stringify({ pid: "x", port: 1 }));
expect(readServerLock(root)).toBeNull();
acquireServerLock(root, { pid: process.pid, port: 12345, startedAt: "2026-01-01T00:00:00Z" });
expect(readServerLock(root)).toEqual({
pid: process.pid,
port: 12345,
startedAt: "2026-01-01T00:00:00Z",
});
});
it("treats a dead pid as stale even if the port is live", async () => {
const { port, close } = await listen();
try {
expect(await isServerLockAlive({ pid: deadPid(), port, startedAt: "" })).toBe(false);
} finally {
await close();
}
});
it("treats a dead port as stale even if the pid is live", async () => {
const { port, close } = await listen();
await close(); // the port is now free — nothing accepts connections
expect(await isServerLockAlive({ pid: process.pid, port, startedAt: "" })).toBe(false);
});
it("reports alive when pid and port both check out, and liveServerLock surfaces it", async () => {
const root = await tempRoot();
const { port, close } = await listen();
try {
const lock = { pid: process.pid, port, startedAt: "now" };
expect(await isServerLockAlive(lock)).toBe(true);
acquireServerLock(root, lock);
expect(await liveServerLock(root)).toEqual(lock);
} finally {
await close();
}
expect(await liveServerLock(root)).toBeNull(); // stale once the listener is gone
});
it("release removes only a lock owned by this process", async () => {
const root = await tempRoot();
acquireServerLock(root, { pid: deadPid(), port: 1, startedAt: "" });
releaseServerLock(root); // foreign pid: left in place
expect(readServerLock(root)).not.toBeNull();
acquireServerLock(root, { pid: process.pid, port: 1, startedAt: "" });
releaseServerLock(root);
expect(readServerLock(root)).toBeNull();
});
});
+3 -2
View File
@@ -1,8 +1,9 @@
import { defineConfig } from "tsup"; import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
// Explicitly name entries to preserve the dist/api/types.js subpath (exports "./api" points to it). // Explicitly name entries to preserve the dist/api/types.js and dist/lock.js subpaths
entry: { index: "src/index.ts", "api/types": "src/api/types.ts" }, // (exports "./api" and "./lock" point to them; lock is side-effect-free for pre-checks).
entry: { index: "src/index.ts", "api/types": "src/api/types.ts", lock: "src/lock.ts" },
format: ["esm"], format: ["esm"],
target: "node22", target: "node22",
dts: true, dts: true,
@@ -14,7 +14,12 @@ import { PasswordInput } from "../ui/password-input";
import { Modal } from "../ui/modal"; import { Modal } from "../ui/modal";
export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose: () => void }) { export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { refresh } = useAuth(); const { refresh, sessionVia } = useAuth();
// Desktop-established sessions set the password without the old one: the desktop seed
// password is random and never shown, so there is nothing to type. Keyed on the
// session's origin, not desktopMode — a browser signed into the same server holds a
// password session and must still prove the current password.
const desktopSession = sessionVia === "desktop";
const [oldPassword, setOldPassword] = useState(""); const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@@ -32,7 +37,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
const submit = async () => { const submit = async () => {
const next: { old?: string; new?: string; confirm?: string } = {}; const next: { old?: string; new?: string; confirm?: string } = {};
if (!oldPassword) next.old = S.common.requiredField; if (!desktopSession && !oldPassword) next.old = S.common.requiredField;
if (!newPassword) next.new = S.common.requiredField; if (!newPassword) next.new = S.common.requiredField;
if (!confirmPassword) next.confirm = S.common.requiredField; if (!confirmPassword) next.confirm = S.common.requiredField;
if (!next.confirm && newPassword !== confirmPassword) next.confirm = S.account.passwordMismatch; if (!next.confirm && newPassword !== confirmPassword) next.confirm = S.account.passwordMismatch;
@@ -43,7 +48,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
setBusy(true); setBusy(true);
setErrors({}); setErrors({});
try { try {
await api.changePassword({ oldPassword, newPassword }); await api.changePassword(desktopSession ? { newPassword } : { oldPassword, newPassword });
await refresh(); await refresh();
onClose(); onClose();
} catch (e) { } catch (e) {
@@ -76,20 +81,22 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
} }
> >
<div className="space-y-3"> <div className="space-y-3">
<PasswordInput {!desktopSession && (
label={S.account.oldPassword} <PasswordInput
required label={S.account.oldPassword}
size="sm" required
value={oldPassword} size="sm"
onChange={(e) => { value={oldPassword}
setOldPassword(e.target.value); onChange={(e) => {
clearErrors(); setOldPassword(e.target.value);
}} clearErrors();
error={errors.old} }}
autoComplete="current-password" error={errors.old}
hint={S.account.oldPasswordHint} autoComplete="current-password"
autoFocus hint={S.account.oldPasswordHint}
/> autoFocus
/>
)}
<PasswordInput <PasswordInput
label={S.account.newPassword} label={S.account.newPassword}
required required
@@ -102,6 +109,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
error={errors.new} error={errors.new}
autoComplete="new-password" autoComplete="new-password"
hint={S.auth.passwordHint} hint={S.auth.passwordHint}
autoFocus={desktopSession}
/> />
<PasswordInput <PasswordInput
label={S.account.confirmPassword} label={S.account.confirmPassword}
@@ -141,7 +141,7 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
} }
export function AppLayout() { export function AppLayout() {
const { user } = useAuth(); const { user, desktopMode } = useAuth();
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [changePasswordOpen, setChangePasswordOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false);
// Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from. // Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from.
@@ -195,8 +195,9 @@ export function AppLayout() {
<span className="text-sm font-semibold">{S.appName}</span> <span className="text-sm font-semibold">{S.appName}</span>
</header> </header>
{/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change */} {/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change.
{user?.passwordIsInitial && ( Hidden in desktop mode — the seed password there is random and never shown, so "change it" is meaningless nagging. */}
{user?.passwordIsInitial && !desktopMode && (
<div className="flex shrink-0 items-center justify-center gap-3 border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300"> <div className="flex shrink-0 items-center justify-center gap-3 border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300">
<span>{S.account.initialPasswordBanner}</span> <span>{S.account.initialPasswordBanner}</span>
<button <button
+62 -53
View File
@@ -192,7 +192,7 @@ export function Sidebar({
onCollapse?: () => void; onCollapse?: () => void;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout, desktopMode } = useAuth();
const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } = const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } =
useTheme(); useTheme();
const { lang, locale, setLang } = useLocale(); const { lang, locale, setLang } = useLocale();
@@ -1115,49 +1115,54 @@ export function Sidebar({
While checking, the label swaps to the busy text and the version stays put. While checking, the label swaps to the busy text and the version stays put.
Nothing is fetched until the menu first opens; the version span appears once Nothing is fetched until the menu first opens; the version span appears once
/api/version resolves. */} /api/version resolves. */}
<button {/* Hidden in desktop mode: updates are the desktop app's job (electron-updater),
type="button" and the dialog's admin self-update re-runs the CLI entry, which does not
disabled={updateChecking} exist under the desktop shell. */}
onClick={() => { {!desktopMode && (
if (newVersion !== null) { <button
setUserOpen(false); type="button"
setUpdateDialogOpen(true); disabled={updateChecking}
} else { onClick={() => {
void runUpdateCheck(); if (newVersion !== null) {
} setUserOpen(false);
}} setUpdateDialogOpen(true);
{...(versionDate !== null } else {
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) } void runUpdateCheck();
: {})} }
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`} }}
> {...(versionDate !== null
<span className="flex min-w-0 items-center gap-2"> ? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
{updateChecking && ( : {})}
<span className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
aria-hidden >
className="inline-block h-3 w-3 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent opacity-70" <span className="flex min-w-0 items-center gap-2">
/> {updateChecking && (
)} <span
{!updateChecking && newVersion !== null && ( aria-hidden
<span className="inline-block h-3 w-3 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent opacity-70"
aria-hidden />
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]" )}
/> {!updateChecking && newVersion !== null && (
)} <span
<span className="min-w-0 truncate"> aria-hidden
{updateChecking className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
? S.update.checking />
: newVersion !== null )}
? S.update.newVersion(newVersion) <span className="min-w-0 truncate">
: S.update.checkNow} {updateChecking
? S.update.checking
: newVersion !== null
? S.update.newVersion(newVersion)
: S.update.checkNow}
</span>
</span> </span>
</span> {version !== null && (
{version !== null && ( <span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500"> {`v${version.version}`}
{`v${version.version}`} </span>
</span> )}
)} </button>
</button> )}
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */} {/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
{user?.isAdmin && ( {user?.isAdmin && (
<button <button
@@ -1171,16 +1176,20 @@ export function Sidebar({
{S.admin.users} {S.admin.users}
</button> </button>
)} )}
<button {/* Hidden in desktop mode: the window IS the session — logging out would
type="button" strand the user on a login page whose password was never shown. */}
className="block w-full px-3.5 py-2 text-left text-sm text-red-600 transition-colors duration-150 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40" {!desktopMode && (
onClick={() => { <button
setUserOpen(false); type="button"
void logout().then(() => navigate("/login")); className="block w-full px-3.5 py-2 text-left text-sm text-red-600 transition-colors duration-150 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
}} onClick={() => {
> setUserOpen(false);
{S.auth.logout} void logout().then(() => navigate("/login"));
</button> }}
>
{S.auth.logout}
</button>
)}
</div> </div>
</Dropdown> </Dropdown>
</div> </div>
+23 -1
View File
@@ -20,6 +20,18 @@ interface AuthContextValue {
* /api/me because it depends on the host the browser is using. * /api/me because it depends on the host the browser is using.
*/ */
previewIsolated: boolean; previewIsolated: boolean;
/**
* Whether the server runs in desktop mode (spawned by the desktop shell). The UI then
* hides the logout entry, the initial-password banner and the self-update entry — the
* desktop app manages sign-in and updates itself.
*/
desktopMode: boolean;
/**
* How THIS session was established. A browser signed into a desktop-mode server holds
* a "password" session; only "desktop" sessions (the shell's window) may change the
* password without the old one.
*/
sessionVia: "password" | "desktop";
login: (userId: string, password: string) => Promise<void>; login: (userId: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
/** Refetch /api/me (e.g. to refresh the passwordIsInitial flag after a password change). */ /** Refetch /api/me (e.g. to refresh the passwordIsInitial flag after a password change). */
@@ -33,6 +45,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Assume isolated until told otherwise: the warning is the exceptional state, and // Assume isolated until told otherwise: the warning is the exceptional state, and
// flashing it during initialization would be noise. // flashing it during initialization would be noise.
const [previewIsolated, setPreviewIsolated] = useState(true); const [previewIsolated, setPreviewIsolated] = useState(true);
const [desktopMode, setDesktopMode] = useState(false);
const [sessionVia, setSessionVia] = useState<"password" | "desktop">("password");
// Any API returning 401 (session expired / database rebuilt) clears the current user, and // Any API returning 401 (session expired / database rebuilt) clears the current user, and
// RequireAuth redirects back to the login page. // RequireAuth redirects back to the login page.
@@ -51,6 +65,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (cancelled) return; if (cancelled) return;
setUser(res.user); setUser(res.user);
setPreviewIsolated(res.previewIsolated); setPreviewIsolated(res.previewIsolated);
setDesktopMode(res.desktopMode);
setSessionVia(res.sessionVia);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (cancelled) return; if (cancelled) return;
@@ -76,6 +92,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const me = await api.getMe(); const me = await api.getMe();
setUser(me.user); setUser(me.user);
setPreviewIsolated(me.previewIsolated); setPreviewIsolated(me.previewIsolated);
setDesktopMode(me.desktopMode);
setSessionVia(me.sessionVia);
} catch { } catch {
// Login itself succeeded; keep the optimistic default. // Login itself succeeded; keep the optimistic default.
} }
@@ -93,10 +111,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const res = await api.getMe(); const res = await api.getMe();
setUser(res.user); setUser(res.user);
setPreviewIsolated(res.previewIsolated); setPreviewIsolated(res.previewIsolated);
setDesktopMode(res.desktopMode);
setSessionVia(res.sessionVia);
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ user, previewIsolated, login, logout, refresh }}> <AuthContext.Provider
value={{ user, previewIsolated, desktopMode, sessionVia, login, logout, refresh }}
>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+97
View File
@@ -112,6 +112,31 @@ importers:
specifier: ^3.2.6 specifier: ^3.2.6
version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.22.4)(yaml@2.9.0) version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.22.4)(yaml@2.9.0)
packages/desktop:
dependencies:
'@prismshadow/penguin-core':
specifier: workspace:*
version: file:packages/core(supports-color@10.2.2)(ws@8.21.0)
'@prismshadow/penguin-server':
specifier: workspace:*
version: link:../server
devDependencies:
'@types/node':
specifier: ^24.0.0
version: 24.13.3
electron:
specifier: ^43.2.0
version: 43.2.0(supports-color@10.2.2)
tsup:
specifier: ^8.3.0
version: 8.5.1(jiti@2.7.0)(postcss@8.5.23)(supports-color@10.2.2)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0)
typescript:
specifier: ^5.6.0
version: 5.9.3
vitest:
specifier: ^3.2.6
version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.22.4)(yaml@2.9.0)
packages/docs: packages/docs:
dependencies: dependencies:
react: react:
@@ -555,6 +580,14 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@electron-internal/extract-zip@1.0.5':
resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==}
engines: {node: '>=22.12.0'}
'@electron/get@5.1.0':
resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==}
engines: {node: '>=22.12.0'}
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1554,6 +1587,11 @@ packages:
electron-to-chromium@1.5.387: electron-to-chromium@1.5.387:
resolution: {integrity: sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==} resolution: {integrity: sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==}
electron@43.2.0:
resolution: {integrity: sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==}
engines: {node: '>= 22.12.0'}
hasBin: true
emoji-regex@10.6.0: emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -1569,6 +1607,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
env-paths@3.0.0:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2243,6 +2285,10 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
property-information@7.2.0: property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
@@ -2352,6 +2398,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
send@0.19.2: send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -2443,6 +2494,10 @@ packages:
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
hasBin: true hasBin: true
sumchecker@3.0.1:
resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==}
engines: {node: '>= 8.0'}
supports-color@10.2.2: supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2552,6 +2607,10 @@ packages:
undici-types@7.18.2: undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.29.0:
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
engines: {node: '>=20.18.1'}
unified@11.0.5: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -3157,6 +3216,21 @@ snapshots:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
'@electron-internal/extract-zip@1.0.5': {}
'@electron/get@5.1.0(supports-color@10.2.2)':
dependencies:
debug: 4.4.3(supports-color@10.2.2)
env-paths: 3.0.0
graceful-fs: 4.2.11
progress: 2.0.3
semver: 7.8.5
sumchecker: 3.0.1(supports-color@10.2.2)
optionalDependencies:
undici: 7.29.0
transitivePeerDependencies:
- supports-color
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
optional: true optional: true
@@ -4100,6 +4174,14 @@ snapshots:
electron-to-chromium@1.5.387: {} electron-to-chromium@1.5.387: {}
electron@43.2.0(supports-color@10.2.2):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0(supports-color@10.2.2)
'@types/node': 24.13.3
transitivePeerDependencies:
- supports-color
emoji-regex@10.6.0: {} emoji-regex@10.6.0: {}
encodeurl@2.0.0: {} encodeurl@2.0.0: {}
@@ -4111,6 +4193,8 @@ snapshots:
entities@6.0.1: {} entities@6.0.1: {}
env-paths@3.0.0: {}
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@@ -5039,6 +5123,8 @@ snapshots:
prettier@3.9.4: {} prettier@3.9.4: {}
progress@2.0.3: {}
property-information@7.2.0: {} property-information@7.2.0: {}
protobufjs@7.6.5: protobufjs@7.6.5:
@@ -5206,6 +5292,8 @@ snapshots:
semver@6.3.1: {} semver@6.3.1: {}
semver@7.8.5: {}
send@0.19.2(supports-color@10.2.2): send@0.19.2(supports-color@10.2.2):
dependencies: dependencies:
debug: 2.6.9(supports-color@10.2.2) debug: 2.6.9(supports-color@10.2.2)
@@ -5333,6 +5421,12 @@ snapshots:
tinyglobby: 0.2.17 tinyglobby: 0.2.17
ts-interface-checker: 0.1.13 ts-interface-checker: 0.1.13
sumchecker@3.0.1(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
supports-color@10.2.2: {} supports-color@10.2.2: {}
tailwindcss@4.3.2: {} tailwindcss@4.3.2: {}
@@ -5431,6 +5525,9 @@ snapshots:
undici-types@7.18.2: {} undici-types@7.18.2: {}
undici@7.29.0:
optional: true
unified@11.0.5: unified@11.0.5:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
+3
View File
@@ -13,3 +13,6 @@ allowBuilds:
"@google/genai": true "@google/genai": true
esbuild: true esbuild: true
protobufjs: true protobufjs: true
# electron's postinstall downloads the platform runtime binary; without this allow
# entry pnpm skips it and `electron .` has nothing to launch.
electron: true