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 Zheng
2026-08-04 16:57:28 +08:00
committed by GitHub
parent 045ac250e0
commit 7015fb0153
35 changed files with 1477 additions and 112 deletions
+27 -1
View File
@@ -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<string | null> {
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 <port>", t.serve.port)
.option("--host <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 <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);
+12
View File
@@ -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} 上通信。`,
+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": {
"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",
+16 -1
View File
@@ -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;
}
+10
View File
@@ -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<AppEnv> {
// 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);
+7 -4
View File
@@ -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<AppEnv> {
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();
};
}
+43 -4
View File
@@ -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<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 {
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;
}
+35 -5
View File
@@ -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,
};
}
+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.
ensureColumn(db, "sessions", "client", "TEXT");
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "auth_sessions", "via", "TEXT");
return db;
}
@@ -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,
};
}
+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
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
+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).
* 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<AppEnv> {
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;
}
@@ -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({
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);
});
+81 -15
View File
@@ -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<typeof serve> | 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<void> {
if (shuttingDown) return;
@@ -80,15 +137,24 @@ async function shutdown(signal: string, exitCode = 0): Promise<void> {
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
+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", () => {
it("unset/empty/whitespace → null; a value is kept trimmed", () => {
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,
authSessionTtlMs: 7 * 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";
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,
@@ -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
}
>
<div className="space-y-3">
<PasswordInput
label={S.account.oldPassword}
required
size="sm"
value={oldPassword}
onChange={(e) => {
setOldPassword(e.target.value);
clearErrors();
}}
error={errors.old}
autoComplete="current-password"
hint={S.account.oldPasswordHint}
autoFocus
/>
{!desktopSession && (
<PasswordInput
label={S.account.oldPassword}
required
size="sm"
value={oldPassword}
onChange={(e) => {
setOldPassword(e.target.value);
clearErrors();
}}
error={errors.old}
autoComplete="current-password"
hint={S.account.oldPasswordHint}
autoFocus
/>
)}
<PasswordInput
label={S.account.newPassword}
required
@@ -102,6 +109,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
error={errors.new}
autoComplete="new-password"
hint={S.auth.passwordHint}
autoFocus={desktopSession}
/>
<PasswordInput
label={S.account.confirmPassword}
@@ -141,7 +141,7 @@ function CollapsedRail({ onExpand }: { onExpand: () => 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() {
<span className="text-sm font-semibold">{S.appName}</span>
</header>
{/* 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 && (
<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>
<button
+62 -53
View File
@@ -192,7 +192,7 @@ export function Sidebar({
onCollapse?: () => void;
}) {
const navigate = useNavigate();
const { user, logout } = useAuth();
const { user, logout, desktopMode } = useAuth();
const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } =
useTheme();
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.
Nothing is fetched until the menu first opens; the version span appears once
/api/version resolves. */}
<button
type="button"
disabled={updateChecking}
onClick={() => {
if (newVersion !== null) {
setUserOpen(false);
setUpdateDialogOpen(true);
} else {
void runUpdateCheck();
}
}}
{...(versionDate !== null
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
: {})}
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
>
<span className="flex min-w-0 items-center gap-2">
{updateChecking && (
<span
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"
/>
)}
{!updateChecking && newVersion !== null && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
/>
)}
<span className="min-w-0 truncate">
{updateChecking
? S.update.checking
: newVersion !== null
? S.update.newVersion(newVersion)
: S.update.checkNow}
{/* Hidden in desktop mode: updates are the desktop app's job (electron-updater),
and the dialog's admin self-update re-runs the CLI entry, which does not
exist under the desktop shell. */}
{!desktopMode && (
<button
type="button"
disabled={updateChecking}
onClick={() => {
if (newVersion !== null) {
setUserOpen(false);
setUpdateDialogOpen(true);
} else {
void runUpdateCheck();
}
}}
{...(versionDate !== null
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
: {})}
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
>
<span className="flex min-w-0 items-center gap-2">
{updateChecking && (
<span
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"
/>
)}
{!updateChecking && newVersion !== null && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
/>
)}
<span className="min-w-0 truncate">
{updateChecking
? S.update.checking
: newVersion !== null
? S.update.newVersion(newVersion)
: S.update.checkNow}
</span>
</span>
</span>
{version !== null && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{`v${version.version}`}
</span>
)}
</button>
{version !== null && (
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
{`v${version.version}`}
</span>
)}
</button>
)}
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
{user?.isAdmin && (
<button
@@ -1171,16 +1176,20 @@ export function Sidebar({
{S.admin.users}
</button>
)}
<button
type="button"
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);
void logout().then(() => navigate("/login"));
}}
>
{S.auth.logout}
</button>
{/* 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 && (
<button
type="button"
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);
void logout().then(() => navigate("/login"));
}}
>
{S.auth.logout}
</button>
)}
</div>
</Dropdown>
</div>
+23 -1
View File
@@ -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<void>;
logout: () => Promise<void>;
/** 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 (
<AuthContext.Provider value={{ user, previewIsolated, login, logout, refresh }}>
<AuthContext.Provider
value={{ user, previewIsolated, desktopMode, sessionVia, login, logout, refresh }}
>
{children}
</AuthContext.Provider>
);
+97
View File
@@ -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
+3
View File
@@ -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