From 7015fb01532f10d626c3b246b4ff5d975d7401ee Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Tue, 4 Aug 2026 16:57:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Electron=20shell=20M2=20?= =?UTF-8?q?=E2=80=94=20embedded=20server,=20desktop=20login,=20instance=20?= =?UTF-8?q?lock=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 --- packages/cli/src/commands/serve.ts | 28 ++- packages/cli/src/i18n.ts | 12 ++ packages/desktop/package.json | 25 +++ packages/desktop/src/main.ts | 202 ++++++++++++++++++ packages/desktop/src/server-process.ts | 139 ++++++++++++ packages/desktop/src/util.ts | 48 +++++ packages/desktop/test/util.test.ts | 49 +++++ packages/desktop/tsconfig.json | 7 + packages/desktop/tsup.config.ts | 14 ++ packages/server/package.json | 4 + packages/server/src/api/types.ts | 17 +- packages/server/src/app.ts | 10 + packages/server/src/auth/middleware.ts | 11 +- packages/server/src/auth/service.ts | 47 +++- packages/server/src/config.ts | 40 +++- packages/server/src/db/database.ts | 1 + packages/server/src/db/repos/auth-sessions.ts | 7 +- packages/server/src/db/schema.ts | 3 +- packages/server/src/http/routes/auth.ts | 20 +- packages/server/src/http/routes/desktop.ts | 32 +++ packages/server/src/http/routes/me.ts | 14 +- packages/server/src/index.ts | 96 +++++++-- packages/server/src/lock.ts | 110 ++++++++++ .../server/src/services/desktop-service.ts | 55 +++++ packages/server/test/config.test.ts | 26 +++ packages/server/test/desktop.test.ts | 170 +++++++++++++++ packages/server/test/helpers.ts | 2 + packages/server/test/lock.test.ts | 107 ++++++++++ packages/server/tsup.config.ts | 5 +- .../account/change-password-dialog.tsx | 42 ++-- .../web/src/components/layout/app-layout.tsx | 7 +- .../web/src/components/layout/sidebar.tsx | 115 +++++----- packages/web/src/state/auth.tsx | 24 ++- pnpm-lock.yaml | 97 +++++++++ pnpm-workspace.yaml | 3 + 35 files changed, 1477 insertions(+), 112 deletions(-) create mode 100644 packages/desktop/package.json create mode 100644 packages/desktop/src/main.ts create mode 100644 packages/desktop/src/server-process.ts create mode 100644 packages/desktop/src/util.ts create mode 100644 packages/desktop/test/util.test.ts create mode 100644 packages/desktop/tsconfig.json create mode 100644 packages/desktop/tsup.config.ts create mode 100644 packages/server/src/http/routes/desktop.ts create mode 100644 packages/server/src/lock.ts create mode 100644 packages/server/src/services/desktop-service.ts create mode 100644 packages/server/test/desktop.test.ts create mode 100644 packages/server/test/lock.test.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index b9288ba..233657b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -16,7 +16,8 @@ */ import { spawn } from "node:child_process"; 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 { 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 * .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 { + const lock = await liveServerLock(process.env.PENGUIN_HOME ?? resolveRoot()); + return lock === null ? null : `http://localhost:${lock.port}/`; +} + async function startServer(opts: { port?: string; host?: string; @@ -243,6 +257,12 @@ export function registerServeCommands(program: Command, t: Messages): void { .option("--port ", t.serve.port) .option("--host ", t.serve.host) .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); }); @@ -253,6 +273,12 @@ export function registerServeCommands(program: Command, t: Messages): void { .option("--host ", t.serve.host) .option("--no-open", t.serve.noOpen) .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 url = browserUrl(host, port); const readiness = await waitForReady(url); diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 092bd07..fae9746 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -223,6 +223,10 @@ export interface Messages { vaultListEmpty(): string; /** URL prompt once the `penguin web` service is ready. */ 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). */ webProbeFailed(url: string, detail: string, kind: WebProbeFailureKind, port: number): string; } @@ -455,6 +459,11 @@ const en: Messages = { vaultListTitle: () => "Vault environment variables (values masked):", vaultListEmpty: () => "The vault is empty. Add one with `penguin config vault set`.", 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) => { const hint = { timeout: @@ -664,6 +673,9 @@ const zh: Messages = { vaultListTitle: () => "vault 环境变量(值已掩码):", vaultListEmpty: () => "vault 为空。用 `penguin config vault set` 添加。", webReady: (url) => `Web 界面已就绪:${url}`, + serverAlreadyRunning: (url) => + `该数据根目录已有 PenguinHarness 服务在运行:${url}\n请先停止它,或用 PENGUIN_HOME 指定另一个数据根目录。`, + webAlreadyRunning: (url) => `该数据根目录已有服务在运行,打开既有实例:${url}`, webProbeFailed: (url, detail, kind, port) => { const hint = { timeout: `连接超时。请检查防火墙或安全软件是否拦截。请允许 PenguinHarness 在本机端口 ${port} 上通信。`, diff --git a/packages/desktop/package.json b/packages/desktop/package.json new file mode 100644 index 0000000..6ff91d5 --- /dev/null +++ b/packages/desktop/package.json @@ -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" + } +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts new file mode 100644 index 0000000..2794747 --- /dev/null +++ b/packages/desktop/src/main.ts @@ -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:/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 | 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 { + 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 { + 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 { + 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); + }); +} diff --git a/packages/desktop/src/server-process.ts b/packages/desktop/src/server-process.ts new file mode 100644 index 0000000..fbcf878 --- /dev/null +++ b/packages/desktop/src/server-process.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + let exited = false; + const exit = new Promise((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)]); + } +} diff --git a/packages/desktop/src/util.ts b/packages/desktop/src/util.ts new file mode 100644 index 0000000..af348e9 --- /dev/null +++ b/packages/desktop/src/util.ts @@ -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); +} diff --git a/packages/desktop/test/util.test.ts b/packages/desktop/test/util.test.ts new file mode 100644 index 0000000..182675a --- /dev/null +++ b/packages/desktop/test/util.test.ts @@ -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]); + }); +}); diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json new file mode 100644 index 0000000..8cd1715 --- /dev/null +++ b/packages/desktop/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/packages/desktop/tsup.config.ts b/packages/desktop/tsup.config.ts new file mode 100644 index 0000000..788f130 --- /dev/null +++ b/packages/desktop/tsup.config.ts @@ -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"], +}); diff --git a/packages/server/package.json b/packages/server/package.json index ee4a113..e4c04eb 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,6 +17,10 @@ "./api": { "types": "./dist/api/types.d.ts", "import": "./dist/api/types.js" + }, + "./lock": { + "types": "./dist/lock.d.ts", + "import": "./dist/lock.js" } }, "main": "./dist/index.js", diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 9efd359..fa588cd 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -72,10 +72,25 @@ export interface MeResponse { * request, since it depends on the host the caller is using. */ 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 { - oldPassword: string; + /** Omitted only by desktop-established sessions (desktop mode); required otherwise. */ + oldPassword?: string; /** At least 8 characters. */ newPassword: string; } diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 89b8135..542db62 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -60,6 +60,8 @@ import { TitleGenerator } from "./runtime/title-generator.js"; import type { TitleNotifier } from "./runtime/title-generator.js"; import { UsageRecorder } from "./runtime/usage-recorder.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 { AgentService } from "./services/agent-service.js"; import { BenchmarkService } from "./services/benchmark-service.js"; @@ -114,6 +116,8 @@ export interface AppDeps { sessionSources: SessionSources; /** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */ 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. */ log: (line: string) => void; } @@ -276,6 +280,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides manager, sessionSources, errors, + desktop: config.desktopToken !== null ? new DesktopService(config.desktopToken) : null, log, }; } @@ -355,6 +360,11 @@ export function createApp(deps: AppDeps): Hono { // Public routes (no login required). 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. const auth = authMiddleware(deps.authService); diff --git a/packages/server/src/auth/middleware.ts b/packages/server/src/auth/middleware.ts index 8487b92..ba39734 100644 --- a/packages/server/src/auth/middleware.ts +++ b/packages/server/src/auth/middleware.ts @@ -11,7 +11,7 @@ import type { MiddlewareHandler } from "hono"; import { getCookie } from "hono/cookie"; import { HttpError } from "../http/errors.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. */ export const SESSION_COOKIE = "penguin_session"; @@ -20,6 +20,8 @@ export const SESSION_COOKIE = "penguin_session"; export type AppEnv = { Variables: { 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 { return async (c, next) => { const token = getCookie(c, SESSION_COOKIE); - const user = token ? auth.authenticate(token) : null; - if (!user) { + const authed = token ? auth.authenticateWithMeta(token) : null; + if (!authed) { 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(); }; } diff --git a/packages/server/src/auth/service.ts b/packages/server/src/auth/service.ts index 6f91779..057b576 100644 --- a/packages/server/src/auth/service.ts +++ b/packages/server/src/auth/service.ts @@ -57,6 +57,14 @@ function sha256Hex(value: string): string { 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 { return { userId: row.userId, @@ -159,7 +167,22 @@ export class AuthService { } this.loginFailures.delete(userId); 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. */ @@ -174,12 +197,25 @@ export class AuthService { 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 { + 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 { this.deps.authSessions.delete(sha256Hex(token)); } /** 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 session = this.deps.authSessions.findByTokenHash(tokenHash); if (!session) return null; @@ -195,10 +231,12 @@ export class AuthService { 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 now = this.now(); this.deps.authSessions.insert({ @@ -206,6 +244,7 @@ export class AuthService { userId, createdAt: now.toISOString(), expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(), + via, }); return token; } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 6219fe2..ab55c2b 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -9,6 +9,7 @@ * detected to exist. * Docs: /docs/configuration § "Environment variables". */ +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; 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), * 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; /** Login session validity period (7 days). */ 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). */ 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; @@ -79,7 +96,7 @@ function normalizePreviewOrigin(raw: string | undefined): string | null { 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 { const root = env.PENGUIN_HOME ?? resolveRoot(); // 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) { 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 { root, - host: env.HOST ?? "127.0.0.1", + host, port, dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"), webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(), previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN), - // An empty/whitespace value is treated as unset (→ random seed password). - seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || null, + // An empty/whitespace value is treated as unset (→ random seed password). Desktop + // 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, authSessionRenewMs: 6 * DAY_MS, + desktopToken, + portFile: env.PENGUIN_PORT_FILE?.trim() || null, }; } diff --git a/packages/server/src/db/database.ts b/packages/server/src/db/database.ts index 3509612..fc83ca3 100644 --- a/packages/server/src/db/database.ts +++ b/packages/server/src/db/database.ts @@ -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. ensureColumn(db, "sessions", "client", "TEXT"); ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn(db, "auth_sessions", "via", "TEXT"); return db; } diff --git a/packages/server/src/db/repos/auth-sessions.ts b/packages/server/src/db/repos/auth-sessions.ts index a7b4a4b..94d3482 100644 --- a/packages/server/src/db/repos/auth-sessions.ts +++ b/packages/server/src/db/repos/auth-sessions.ts @@ -10,6 +10,8 @@ export interface AuthSessionRow { userId: string; createdAt: 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 { @@ -18,9 +20,9 @@ export class AuthSessionsRepo { insert(row: AuthSessionRow): void { this.db .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 { @@ -31,6 +33,7 @@ export class AuthSessionsRepo { userId: r.user_id as string, createdAt: r.created_at as string, expiresAt: r.expires_at as string, + via: (r.via as string | null) ?? null, }; } diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index ffbe4ca..bb5c920 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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 user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, 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 ( project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml diff --git a/packages/server/src/http/routes/auth.ts b/packages/server/src/http/routes/auth.ts index b795f29..6290672 100644 --- a/packages/server/src/http/routes/auth.ts +++ b/packages/server/src/http/routes/auth.ts @@ -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). * Login issues a cookie session; logout deletes the server-side session and clears the cookie. */ import { Hono } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import type { AuthResponse } from "../../api/types.js"; +import { HttpError } from "../errors.js"; import { SESSION_COOKIE } from "../../auth/middleware.js"; import type { AppEnv } from "../../auth/middleware.js"; import { readJson, requireString } from "../validate.js"; @@ -42,5 +43,22 @@ export function authRoutes(deps: AppDeps): Hono { 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; } diff --git a/packages/server/src/http/routes/desktop.ts b/packages/server/src/http/routes/desktop.ts new file mode 100644 index 0000000..2ee692f --- /dev/null +++ b/packages/server/src/http/routes/desktop.ts @@ -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; +} diff --git a/packages/server/src/http/routes/me.ts b/packages/server/src/http/routes/me.ts index 2da9a45..70e0331 100644 --- a/packages/server/src/http/routes/me.ts +++ b/packages/server/src/http/routes/me.ts @@ -28,15 +28,25 @@ export function meRoutes(deps: AppDeps): Hono { return c.json({ user: toUserInfo(c.var.user), previewIsolated: target !== null, + desktopMode: deps.desktop !== null, + sessionVia: c.var.sessionVia, } satisfies MeResponse); }); // 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) => { const body = await readJson(c); - const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" }); 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); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9f3a9f9..4df3257 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,15 +9,35 @@ * persist + log, with the fatal one still shutting down per existing semantics (see the * comment below). */ +import fs from "node:fs"; +import path from "node:path"; import { config as loadDotenv } from "dotenv"; import { serve } from "@hono/node-server"; import { buildAppDeps, createApp } from "./app.js"; import { resolveServerConfig } from "./config.js"; import { loopbackHostRoles } from "./services/preview-token.js"; +import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js"; loadDotenv({ quiet: true }); +/** Exit code for "another server already owns this data root" (see lock.ts). */ +const EXIT_ALREADY_RUNNING = 3; + 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 app = createApp(deps); @@ -25,7 +45,10 @@ const app = createApp(deps); // 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. 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( `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 // only 302s back here for App routes (see the canonical-host guard in app.ts). 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. @@ -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` * 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. + * + * 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 = - config.host === "127.0.0.1" || config.host === "localhost" - ? serve({ fetch: app.fetch, hostname: "::1", port: config.port }) - : null; -ipv6Loopback?.on("error", (err: NodeJS.ErrnoException) => { - console.warn( - `[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`, - ); +let ipv6Loopback: ReturnType | null = null; + +/** Port announcement (PENGUIN_PORT_FILE): tmp + rename, so a polling reader never sees a partial write. */ +function writePortFile(file: string, port: number): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + 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; async function shutdown(signal: string, exitCode = 0): Promise { if (shuttingDown) return; @@ -80,15 +137,24 @@ async function shutdown(signal: string, exitCode = 0): Promise { ipv6Loopback?.close(); server.close(() => { deps.db.close(); + cleanupInstanceFiles(); process.exit(exitCode); }); // 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("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 // 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 diff --git a/packages/server/src/lock.ts b/packages/server/src/lock.ts new file mode 100644 index 0000000..80aae7f --- /dev/null +++ b/packages/server/src/lock.ts @@ -0,0 +1,110 @@ +/** + * Root-level server instance lock (`/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; + 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 { + 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 { + 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 { + 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. + } +} diff --git a/packages/server/src/services/desktop-service.ts b/packages/server/src/services/desktop-service.ts new file mode 100644 index 0000000..f978c9d --- /dev/null +++ b/packages/server/src/services/desktop-service.ts @@ -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 ) — 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; + } +} diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts index b7eb8a1..254d439 100644 --- a/packages/server/test/config.test.ts +++ b/packages/server/test/config.test.ts @@ -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", () => { it("unset/empty/whitespace → null; a value is kept trimmed", () => { expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull(); diff --git a/packages/server/test/desktop.test.ts b/packages/server/test/desktop.test.ts new file mode 100644 index 0000000..1b63680 --- /dev/null +++ b/packages/server/test/desktop.test.ts @@ -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>): Promise { + 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(); + } + }); +}); diff --git a/packages/server/test/helpers.ts b/packages/server/test/helpers.ts index 69e1a88..3dd82b5 100644 --- a/packages/server/test/helpers.ts +++ b/packages/server/test/helpers.ts @@ -39,6 +39,8 @@ export function testConfig(root: string): ServerConfig { seedAdminPassword: TEST_ADMIN_PASSWORD, authSessionTtlMs: 7 * DAY_MS, authSessionRenewMs: 6 * DAY_MS, + desktopToken: null, + portFile: null, }; } diff --git a/packages/server/test/lock.test.ts b/packages/server/test/lock.test.ts new file mode 100644 index 0000000..cd4928b --- /dev/null +++ b/packages/server/test/lock.test.ts @@ -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 }> { + 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 { + 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(); + }); +}); diff --git a/packages/server/tsup.config.ts b/packages/server/tsup.config.ts index 9267bed..794fa73 100644 --- a/packages/server/tsup.config.ts +++ b/packages/server/tsup.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from "tsup"; export default defineConfig({ - // Explicitly name entries to preserve the dist/api/types.js subpath (exports "./api" points to it). - entry: { index: "src/index.ts", "api/types": "src/api/types.ts" }, + // Explicitly name entries to preserve the dist/api/types.js and dist/lock.js subpaths + // (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"], target: "node22", dts: true, diff --git a/packages/web/src/components/account/change-password-dialog.tsx b/packages/web/src/components/account/change-password-dialog.tsx index 74fb580..8a5c257 100644 --- a/packages/web/src/components/account/change-password-dialog.tsx +++ b/packages/web/src/components/account/change-password-dialog.tsx @@ -14,7 +14,12 @@ import { PasswordInput } from "../ui/password-input"; import { Modal } from "../ui/modal"; 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 [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -32,7 +37,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose const submit = async () => { 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 (!confirmPassword) next.confirm = S.common.requiredField; if (!next.confirm && newPassword !== confirmPassword) next.confirm = S.account.passwordMismatch; @@ -43,7 +48,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose setBusy(true); setErrors({}); try { - await api.changePassword({ oldPassword, newPassword }); + await api.changePassword(desktopSession ? { newPassword } : { oldPassword, newPassword }); await refresh(); onClose(); } catch (e) { @@ -76,20 +81,22 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose } >
- { - setOldPassword(e.target.value); - clearErrors(); - }} - error={errors.old} - autoComplete="current-password" - hint={S.account.oldPasswordHint} - autoFocus - /> + {!desktopSession && ( + { + setOldPassword(e.target.value); + clearErrors(); + }} + error={errors.old} + autoComplete="current-password" + hint={S.account.oldPasswordHint} + autoFocus + /> + )} void }) { } export function AppLayout() { - const { user } = useAuth(); + const { user, desktopMode } = useAuth(); const [drawerOpen, setDrawerOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false); // Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from. @@ -195,8 +195,9 @@ export function AppLayout() { {S.appName} - {/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change */} - {user?.passwordIsInitial && ( + {/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change. + Hidden in desktop mode — the seed password there is random and never shown, so "change it" is meaningless nagging. */} + {user?.passwordIsInitial && !desktopMode && (
{S.account.initialPasswordBanner} + {version !== null && ( + + {`v${version.version}`} + + )} + + )} {/* User management is visible only to admins (the page route also has its own guard as a fallback). */} {user?.isAdmin && ( + {/* Hidden in desktop mode: the window IS the session — logging out would + strand the user on a login page whose password was never shown. */} + {!desktopMode && ( + + )}
diff --git a/packages/web/src/state/auth.tsx b/packages/web/src/state/auth.tsx index 9d3122f..8221839 100644 --- a/packages/web/src/state/auth.tsx +++ b/packages/web/src/state/auth.tsx @@ -20,6 +20,18 @@ interface AuthContextValue { * /api/me because it depends on the host the browser is using. */ 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; logout: () => Promise; /** 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 // flashing it during initialization would be noise. 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 // RequireAuth redirects back to the login page. @@ -51,6 +65,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (cancelled) return; setUser(res.user); setPreviewIsolated(res.previewIsolated); + setDesktopMode(res.desktopMode); + setSessionVia(res.sessionVia); }) .catch((err: unknown) => { if (cancelled) return; @@ -76,6 +92,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const me = await api.getMe(); setUser(me.user); setPreviewIsolated(me.previewIsolated); + setDesktopMode(me.desktopMode); + setSessionVia(me.sessionVia); } catch { // Login itself succeeded; keep the optimistic default. } @@ -93,10 +111,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { const res = await api.getMe(); setUser(res.user); setPreviewIsolated(res.previewIsolated); + setDesktopMode(res.desktopMode); + setSessionVia(res.sessionVia); }, []); return ( - + {children} ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 291e339..854a55f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,31 @@ importers: 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/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: dependencies: react: @@ -555,6 +580,14 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} 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': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -1554,6 +1587,11 @@ packages: electron-to-chromium@1.5.387: 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: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1569,6 +1607,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} 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: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2243,6 +2285,10 @@ packages: engines: {node: '>=14'} hasBin: true + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -2352,6 +2398,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -2443,6 +2494,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2552,6 +2607,10 @@ packages: undici-types@7.18.2: 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: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -3157,6 +3216,21 @@ snapshots: '@babel/helper-string-parser': 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': optional: true @@ -4100,6 +4174,14 @@ snapshots: 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: {} encodeurl@2.0.0: {} @@ -4111,6 +4193,8 @@ snapshots: entities@6.0.1: {} + env-paths@3.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5039,6 +5123,8 @@ snapshots: prettier@3.9.4: {} + progress@2.0.3: {} + property-information@7.2.0: {} protobufjs@7.6.5: @@ -5206,6 +5292,8 @@ snapshots: semver@6.3.1: {} + semver@7.8.5: {} + send@0.19.2(supports-color@10.2.2): dependencies: debug: 2.6.9(supports-color@10.2.2) @@ -5333,6 +5421,12 @@ snapshots: tinyglobby: 0.2.17 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: {} tailwindcss@4.3.2: {} @@ -5431,6 +5525,9 @@ snapshots: undici-types@7.18.2: {} + undici@7.29.0: + optional: true + unified@11.0.5: dependencies: '@types/unist': 3.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 444ee0f..bdee814 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,3 +13,6 @@ allowBuilds: "@google/genai": true esbuild: 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