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} 上通信。`,