diff --git a/packages/desktop/build/icon.png b/packages/desktop/build/icon.png new file mode 100644 index 0000000..90beb5f Binary files /dev/null and b/packages/desktop/build/icon.png differ diff --git a/packages/desktop/build/icons/128x128.png b/packages/desktop/build/icons/128x128.png new file mode 100644 index 0000000..d786d44 Binary files /dev/null and b/packages/desktop/build/icons/128x128.png differ diff --git a/packages/desktop/build/icons/256x256.png b/packages/desktop/build/icons/256x256.png new file mode 100644 index 0000000..4e964b7 Binary files /dev/null and b/packages/desktop/build/icons/256x256.png differ diff --git a/packages/desktop/build/icons/512x512.png b/packages/desktop/build/icons/512x512.png new file mode 100644 index 0000000..9ddc4a2 Binary files /dev/null and b/packages/desktop/build/icons/512x512.png differ diff --git a/packages/desktop/build/linux/after-install.tpl b/packages/desktop/build/linux/after-install.tpl new file mode 100644 index 0000000..19573c8 --- /dev/null +++ b/packages/desktop/build/linux/after-install.tpl @@ -0,0 +1,69 @@ +#!/bin/bash +# Deb post-install (electron-builder deb.afterInstall). Overriding the option REPLACES +# electron-builder's default template, so this file is that default (app-builder-lib +# templates/linux/after-install.tpl, v26.15.3) verbatim, plus the marked PenguinHarness +# section exposing the bundled `penguin` CLI launcher on PATH. The dollar-brace forms +# are electron-builder template macros (executable, sanitizedProductName); any other +# all-letter dollar-brace token fails the build, so shell variables stay brace-less. + +if type update-alternatives >/dev/null 2>&1; then + # Remove previous link if it doesn't use update-alternatives + if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then + rm -f '/usr/bin/${executable}' + fi + update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +else + ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +fi + +# PenguinHarness: expose the bundled penguin CLI launcher on PATH. Never clobber a real +# file of that name; replacing a (possibly stale) symlink keeps re-installs idempotent. +if [ ! -e '/usr/bin/penguin' ] || [ -L '/usr/bin/penguin' ]; then + ln -sf '/opt/${sanitizedProductName}/resources/app/bin/penguin' '/usr/bin/penguin' +fi + +# Check if user namespaces are supported by the kernel and working with a quick test: +if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then + # Use SUID chrome-sandbox only on systems without user namespaces: + chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true +else + chmod 0755 '/opt/${sanitizedProductName}/chrome-sandbox' || true +fi + +if hash update-mime-database 2>/dev/null; then + update-mime-database /usr/share/mime || true +fi + +if hash update-desktop-database 2>/dev/null; then + update-desktop-database /usr/share/applications || true +fi + +# Install apparmor profile. (Ubuntu 24+) +# First check if the version of AppArmor running on the device supports our profile. +# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0. +# In that case, we just skip installing the profile since the app runs fine without it on 22.04. +# +# Those apparmor_parser flags are akin to performing a dry run of loading a profile. +# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles +# +# Unfortunately, at the moment AppArmor doesn't have a good story for backwards compatibility. +# https://askubuntu.com/questions/1517272/writing-a-backwards-compatible-apparmor-profile +if apparmor_status --enabled > /dev/null 2>&1; then + APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile' + APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}' + if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then + cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET" + + # Updating the current AppArmor profile is not possible and probably not meaningful in a chroot'ed environment. + # Use cases are for example environments where images for clients are maintained. + # There, AppArmor might correctly be installed, but live updating makes no sense. + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + # Extra flags taken from dh_apparmor: + # > By using '-W -T' we ensure that any abstraction updates are also pulled in. + # https://wiki.debian.org/AppArmor/Contribute/FirstTimeProfileImport + apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET" + fi + else + echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile" + fi +fi diff --git a/packages/desktop/build/linux/after-remove.tpl b/packages/desktop/build/linux/after-remove.tpl new file mode 100644 index 0000000..ce2523c --- /dev/null +++ b/packages/desktop/build/linux/after-remove.tpl @@ -0,0 +1,36 @@ +#!/bin/bash +# Deb post-remove (electron-builder deb.afterRemove). Overriding the option REPLACES +# electron-builder's default template, so this file is that default (app-builder-lib +# templates/linux/after-remove.tpl, v26.15.3) verbatim, plus the marked PenguinHarness +# section removing the `penguin` CLI launcher link installed by after-install.tpl. + +# Delete the link to the binary +# update-alternatives --remove : 'path' must be the registered alternative binary, +# not the generic symlink — see https://man7.org/linux/man-pages/man1/update-alternatives.1.html +if type update-alternatives >/dev/null 2>&1; then + update-alternatives --remove '${executable}' '/opt/${sanitizedProductName}/${executable}' +else + rm -f '/usr/bin/${executable}' +fi + +# PenguinHarness: remove the penguin CLI launcher link, but only if it is ours. +if [ -L '/usr/bin/penguin' ] && [ "`readlink '/usr/bin/penguin'`" = '/opt/${sanitizedProductName}/resources/app/bin/penguin' ]; then + rm -f '/usr/bin/penguin' +fi + +APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}' + +# Remove and unload apparmor profile. +if [ -f "$APPARMOR_PROFILE_DEST" ]; then + # Unload the profile from the running kernel before deleting the file so the + # policy is not left enforced until the next reboot. Mirror the chroot guard + # used in the after-install script — live AppArmor operations are not + # meaningful inside a chroot. + # https://wiki.debian.org/AppArmor/HowToUse + if apparmor_status --enabled > /dev/null 2>&1; then + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + apparmor_parser --remove "$APPARMOR_PROFILE_DEST" || true + fi + fi + rm -f "$APPARMOR_PROFILE_DEST" +fi diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 48ed21d..7d7068f 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -24,6 +24,10 @@ nodeGypRebuild: false mac: category: public.app-category.developer-tools + # Brand icon (rendered from packages/web/public/penguin-logo.svg by + # scripts/render-icon.mjs, committed): electron-builder converts the 1024px PNG to + # icns itself (app-builder-lib iconConverter; >=512px required). + icon: build/icon.png # No Developer ID yet (M4): identity null skips codesign instead of failing. identity: null # Version-less artifact names, following the CLI bundles (penguin-darwin-arm64.tar.gz): @@ -38,6 +42,9 @@ mac: arch: [arm64, x64] win: + # Same source PNG; electron-builder converts it to the multi-size ico embedded in the + # exe (>=256px required). + icon: build/icon.png artifactName: penguin-desktop-win32-${arch}.${ext} target: - target: nsis @@ -54,6 +61,9 @@ nsis: allowToChangeInstallationDirectory: true linux: + # Pre-rendered freedesktop icon set (scripts/render-icon.mjs): a directory of NxN.png + # files is used as-is for AppImage/deb, no conversion step. + icon: build/icons # The scoped package name is not a valid executable file name. executableName: penguin-harness # ${arch} expands to each Linux target's own convention — x86_64 for AppImage, amd64 @@ -68,3 +78,11 @@ linux: arch: [x64] - target: deb arch: [x64] + +deb: + # Extended copies of electron-builder's default scripts (overriding REPLACES them): + # they additionally create/remove the /usr/bin/penguin symlink to the staged CLI + # launcher (resources/app/bin/penguin), so a deb install gets the `penguin` command + # on PATH without the in-app install flow the other formats use. + afterInstall: build/linux/after-install.tpl + afterRemove: build/linux/after-remove.tpl diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b953737..827aead 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -17,6 +17,7 @@ "pack:win": "node scripts/stage.mjs && electron-builder --win" }, "dependencies": { + "@prismshadow/penguin-cli": "workspace:*", "@prismshadow/penguin-core": "workspace:*", "@prismshadow/penguin-server": "workspace:*" }, diff --git a/packages/desktop/scripts/render-icon.mjs b/packages/desktop/scripts/render-icon.mjs new file mode 100644 index 0000000..d51a0ac --- /dev/null +++ b/packages/desktop/scripts/render-icon.mjs @@ -0,0 +1,72 @@ +/** + * Render the app icon PNGs from the brand mark (packages/web/public/penguin-logo.svg, + * treated as immutable — landing/docs carry byte-identical copies). + * + * Outputs (COMMITTED — regenerate only when the SVG changes): + * - build/icon.png 1024×1024. electron-builder converts it to icns (mac, + * >=512px required) and ico (win, >=256px) at pack time; + * also the runtime BrowserWindow icon (see src/app-icon.ts). + * - build/icons/x.png 128/256/512 freedesktop set for the Linux targets + * (used as-is, no conversion). + * + * Regenerate: node packages/desktop/scripts/render-icon.mjs + * Rasterizes via the Playwright chromium already installed for packages/landing (no new + * dependency; precedent: packages/landing/scripts/capture-readme-demo.mjs). Each size is + * rendered at its native resolution (no downscaling), with a transparent background so + * the SVG's rounded-rect clip keeps the corners transparent. + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG_DIR = path.resolve(HERE, ".."); +const REPO_ROOT = path.resolve(PKG_DIR, "..", ".."); +const SVG_PATH = path.join(REPO_ROOT, "packages", "web", "public", "penguin-logo.svg"); +const BUILD_DIR = path.join(PKG_DIR, "build"); +const ICON_SET_DIR = path.join(BUILD_DIR, "icons"); + +// Resolve @playwright/test from the landing package's context (it is not a dependency +// of this package, and must not become one). +const requireLanding = createRequire(path.join(REPO_ROOT, "packages", "landing", "package.json")); +const { chromium } = requireLanding("@playwright/test"); + +const svgDataUrl = `data:image/svg+xml;base64,${readFileSync(SVG_PATH).toString("base64")}`; + +/** [size, output path]; icon.png is the 1024 master, the rest form the Linux icon set. */ +const targets = [ + [1024, path.join(BUILD_DIR, "icon.png")], + [512, path.join(ICON_SET_DIR, "512x512.png")], + [256, path.join(ICON_SET_DIR, "256x256.png")], + [128, path.join(ICON_SET_DIR, "128x128.png")], +]; + +mkdirSync(ICON_SET_DIR, { recursive: true }); + +const browser = await chromium.launch(); +try { + for (const [size, outPath] of targets) { + const page = await browser.newPage({ + viewport: { width: size, height: size }, + deviceScaleFactor: 1, + }); + await page.setContent( + `` + + ``, + ); + await page.evaluate(() => document.querySelector("img").decode()); + const png = await page.screenshot({ + omitBackground: true, + clip: { x: 0, y: 0, width: size, height: size }, + }); + writeFileSync(outPath, png); + console.log( + `[render-icon] ${path.relative(PKG_DIR, outPath)} (${size}x${size}, ${png.length} bytes)`, + ); + await page.close(); + } +} finally { + await browser.close(); +} diff --git a/packages/desktop/scripts/stage.mjs b/packages/desktop/scripts/stage.mjs index b9fc385..7d7c8e3 100644 --- a/packages/desktop/scripts/stage.mjs +++ b/packages/desktop/scripts/stage.mjs @@ -2,20 +2,25 @@ * Assemble the self-contained app directory electron-builder packs (stage/app). * * `pnpm deploy --prod` materializes this package plus its production dependency tree — - * including the workspace packages — into a portable directory whose symlinks all stay - * inside it (verified: the server boots from the deploy dir as-is). On top of that: + * including the workspace packages (@prismshadow/penguin-cli among them, so the staged + * node_modules carries cli + server + core + skills) — into a portable directory whose + * symlinks all stay inside it (verified: the server boots from the deploy dir as-is). + * On top of that: * - prune dev files (sources, configs) so only dist/, node_modules/ and package.json ship; * - copy the web build to `node_modules/@prismshadow/penguin-server/web-dist`, the npm * package layout the server's static-hosting lookup checks first; + * - copy build/icon.png into the app dir (the runtime window icon, see src/app-icon.ts); + * - generate the `penguin` CLI launchers into `bin/` (POSIX + Windows, see + * src/launcher.ts): they run the bundled CLI on the app's Electron runtime as Node; * - ensure `stage/minigit` exists (may be empty): the Windows CI job downloads MinGit * into it, and electron-builder's win extraResources entry must always have a source. * - * Run from anywhere; all paths are derived from this file's location. + * Run from anywhere (after `pnpm -r build`); all paths derive from this file's location. */ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = path.resolve(pkgDir, "..", ".."); @@ -76,6 +81,44 @@ if (!fs.existsSync(path.join(webDist, "index.html"))) { const serverPkg = path.join(appDir, "node_modules", "@prismshadow", "penguin-server"); fs.cpSync(webDist, path.join(serverPkg, "web-dist"), { recursive: true, dereference: true }); +// The shell forks the server by this exact path, and the CLI launchers point at the +// CLI entry: verify both landed in the deploy tree before packing. +const launcherModule = path.join(pkgDir, "dist", "launcher.js"); +for (const [what, file] of [ + ["server entry", path.join(serverPkg, "dist", "index.js")], + [ + "CLI entry", + path.join(appDir, "node_modules", "@prismshadow", "penguin-cli", "dist", "index.js"), + ], + ["launcher generator (dist/launcher.js)", launcherModule], +]) { + if (!fs.existsSync(file)) { + console.error(`[stage] missing ${what} (${file}) — run \`pnpm -r build\` first.`); + process.exit(1); + } +} + +// Runtime window icon: same app-dir-relative location as the dev run (build/icon.png), +// so src/app-icon.ts resolves it identically in both layouts. +const iconSrc = path.join(pkgDir, "build", "icon.png"); +if (!fs.existsSync(iconSrc)) { + console.error("[stage] build/icon.png is missing — run `node scripts/render-icon.mjs`."); + process.exit(1); +} +fs.mkdirSync(path.join(appDir, "build"), { recursive: true }); +fs.copyFileSync(iconSrc, path.join(appDir, "build", "icon.png")); + +// CLI launchers (bin/penguin, bin/penguin.cmd): generated, not committed — the script +// text lives in src/launcher.ts so it is unit-tested with the rest of the shell. +const { posixLauncherScript, windowsLauncherScript } = await import( + pathToFileURL(launcherModule).href +); +const binDir = path.join(appDir, "bin"); +fs.mkdirSync(binDir, { recursive: true }); +fs.writeFileSync(path.join(binDir, "penguin"), posixLauncherScript(), { mode: 0o755 }); +fs.chmodSync(path.join(binDir, "penguin"), 0o755); +fs.writeFileSync(path.join(binDir, "penguin.cmd"), windowsLauncherScript()); + fs.mkdirSync(path.join(stageDir, "minigit"), { recursive: true }); console.log("[stage] done:", appDir); diff --git a/packages/desktop/src/app-icon.ts b/packages/desktop/src/app-icon.ts new file mode 100644 index 0000000..79e58ea --- /dev/null +++ b/packages/desktop/src/app-icon.ts @@ -0,0 +1,27 @@ +/** + * Runtime window icon — pure path logic, no Electron imports (unit-tested). + * + * Only Linux (and Windows dev runs) need it: a packaged Windows app gets its taskbar + * icon from the exe resources electron-builder embeds, and macOS ignores BrowserWindow + * icons entirely (the Dock icon comes from the bundle's icns). The PNG lives at + * build/icon.png both in the source package (committed, rendered by + * scripts/render-icon.mjs) and in the staged app directory (copied by scripts/stage.mjs), + * so the same app-path-relative lookup serves dev and packaged runs. + */ +import fs from "node:fs"; +import path from "node:path"; + +/** Window icon location relative to the app directory (dev package dir / staged app dir). */ +export const WINDOW_ICON_RELPATH = ["build", "icon.png"]; + +/** The window-icon path for a platform, or null where window icons are not used (macOS). */ +export function windowIconPathFor(appPath: string, platform: NodeJS.Platform): string | null { + if (platform === "darwin") return null; + return path.join(appPath, ...WINDOW_ICON_RELPATH); +} + +/** Same, but only when the file actually exists (a missing icon must not break windows). */ +export function resolveWindowIcon(appPath: string, platform: NodeJS.Platform): string | null { + const iconPath = windowIconPathFor(appPath, platform); + return iconPath !== null && fs.existsSync(iconPath) ? iconPath : null; +} diff --git a/packages/desktop/src/cli-install.ts b/packages/desktop/src/cli-install.ts new file mode 100644 index 0000000..7070320 --- /dev/null +++ b/packages/desktop/src/cli-install.ts @@ -0,0 +1,212 @@ +/** + * "Install 'penguin' Command" — PATH exposure for the bundled CLI launcher + * (/bin/penguin, generated at stage time; see launcher.ts). + * + * Per platform (deb is absent on purpose — its postinst ships /usr/bin/penguin, see + * build/linux/after-install.tpl): + * - macOS: symlink /usr/local/bin/penguin → /bin/penguin; on permission errors, + * escalate ONCE via osascript "with administrator privileges". + * - Windows: append \bin to the user PATH (HKCU\Environment) via reg.exe, + * idempotently (read + compare first); new terminals pick it up. + * - Linux AppImage: write an executable ~/.local/bin/penguin wrapper that runs the + * AppImage itself as Node (see launcher.ts appImageWrapperScript). + * + * Everything is native UI (menu item + dialogs) in English: the main process stays + * outside the web app's i18n, and per the design the desktop shell talks to the page + * only through the server's HTTP API — never a private IPC channel. + */ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { app, dialog } from "electron"; +import type { BrowserWindow } from "electron"; +import { appImageWrapperScript, cliInstallKind, mergeWindowsUserPath } from "./launcher.js"; +import type { CliInstallKind } from "./launcher.js"; + +const execFileAsync = promisify(execFile); + +/** The staged launcher directory inside the packaged app. */ +function binDir(): string { + return path.join(app.getAppPath(), "bin"); +} + +/** This run's install kind, or null when there is nothing to install (dev run / deb). */ +export function currentCliInstallKind(): CliInstallKind | null { + return cliInstallKind({ + packaged: app.isPackaged, + platform: process.platform, + appImagePath: process.env.APPIMAGE ?? null, + }); +} + +function showResult(win: BrowserWindow | null, ok: boolean, detail: string): void { + const opts = { + type: ok ? ("info" as const) : ("error" as const), + title: "PenguinHarness", + message: ok + ? "The 'penguin' command is installed." + : "Could not install the 'penguin' command.", + detail, + }; + void (win !== null ? dialog.showMessageBox(win, opts) : dialog.showMessageBox(opts)); +} + +/** macOS: /usr/local/bin/penguin symlink, escalating once via osascript on EACCES/EPERM. */ +async function installDarwin(win: BrowserWindow | null): Promise { + const target = path.join(binDir(), "penguin"); + const link = "/usr/local/bin/penguin"; + try { + fs.mkdirSync("/usr/local/bin", { recursive: true }); + fs.rmSync(link, { force: true }); + fs.symlinkSync(target, link); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EACCES" && code !== "EPERM") { + showResult(win, false, String(err)); + return; + } + // Privileged retry; paths contain no single quotes (the bundle path is fixed and + // /Applications-style paths at most contain spaces). + const shellCmd = `mkdir -p /usr/local/bin && ln -sf '${target}' '${link}'`; + try { + await execFileAsync("osascript", [ + "-e", + `do shell script "${shellCmd.replace(/"/g, '\\"')}" with administrator privileges`, + ]); + } catch (escalated) { + showResult( + win, + false, + `Administrator authorization failed or was cancelled.\n${String(escalated)}`, + ); + return; + } + } + showResult( + win, + true, + `${link} now points at the app's bundled CLI. Run 'penguin' from any terminal.`, + ); +} + +/** Windows: idempotent HKCU\Environment PATH append via reg.exe. */ +async function installWindows(win: BrowserWindow | null): Promise { + const dir = binDir(); + let current: string | null = null; + try { + const { stdout } = await execFileAsync("reg", ["query", "HKCU\\Environment", "/v", "Path"]); + // Output line: " Path REG_EXPAND_SZ C:\foo;C:\bar" + const m = /^\s*Path\s+REG(?:_EXPAND)?_SZ\s+(.*)$/im.exec(stdout); + if (m) current = m[1]!.trim(); + } catch { + current = null; // No user Path value yet. + } + const merged = mergeWindowsUserPath(current, dir); + if (merged === null) { + showResult(win, true, `${dir} is already on your PATH. Run 'penguin' from any terminal.`); + return; + } + try { + // REG_EXPAND_SZ keeps any %VAR% entries of the existing value expandable. + await execFileAsync("reg", [ + "add", + "HKCU\\Environment", + "/v", + "Path", + "/t", + "REG_EXPAND_SZ", + "/d", + merged, + "/f", + ]); + } catch (err) { + showResult(win, false, String(err)); + return; + } + showResult( + win, + true, + `${dir} was added to your user PATH. Open a NEW terminal (existing ones keep the old PATH) and run 'penguin'.`, + ); +} + +/** Linux AppImage: executable ~/.local/bin/penguin wrapper invoking the AppImage as Node. */ +function installAppImage(win: BrowserWindow | null): void { + const appImage = process.env.APPIMAGE; + if (!appImage) { + showResult(win, false, "APPIMAGE is not set; this build cannot install the command."); + return; + } + const dir = path.join(os.homedir(), ".local", "bin"); + const wrapper = path.join(dir, "penguin"); + let script: string; + try { + script = appImageWrapperScript(appImage); + } catch (err) { + showResult(win, false, String(err)); + return; + } + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(wrapper, script, { mode: 0o755 }); + fs.chmodSync(wrapper, 0o755); // writeFileSync mode is ignored when the file existed. + } catch (err) { + showResult(win, false, String(err)); + return; + } + showResult( + win, + true, + `${wrapper} now runs the CLI bundled in this AppImage. Make sure ~/.local/bin is on your PATH (most distributions add it at login), then run 'penguin' from a new terminal. Re-run this menu item if you move the AppImage.`, + ); +} + +/** Runs the platform installer (menu item and first-launch offer both land here). */ +export async function installCliCommand(win: BrowserWindow | null): Promise { + switch (currentCliInstallKind()) { + case "darwin": + await installDarwin(win); + return; + case "windows": + await installWindows(win); + return; + case "appimage": + installAppImage(win); + return; + case null: + showResult(win, false, "This build has no bundled CLI to install (development run)."); + } +} + +/** + * One-time first-launch offer: asks once per installation whether to install the + * command, and never again (the app menu keeps the entry point available). The + * "offered" flag persists under userData next to the shell's other state files. + */ +export async function maybeOfferCliInstall(win: BrowserWindow | null): Promise { + if (currentCliInstallKind() === null) return; + const flag = path.join(app.getPath("userData"), "cli-install-offered"); + try { + if (fs.existsSync(flag)) return; + fs.mkdirSync(path.dirname(flag), { recursive: true }); + fs.writeFileSync(flag, `${new Date().toISOString()}\n`); + } catch { + return; // Unreadable/unwritable userData: skip rather than re-offer forever. + } + const opts = { + type: "question" as const, + title: "PenguinHarness", + message: "Install the 'penguin' command line tool?", + detail: + "Makes the CLI bundled with this app available in your terminal. You can do this later from the application menu: Install 'penguin' Command.", + buttons: ["Install", "Not Now"], + defaultId: 0, + cancelId: 1, + }; + const { response } = await (win !== null + ? dialog.showMessageBox(win, opts) + : dialog.showMessageBox(opts)); + if (response === 0) await installCliCommand(win); +} diff --git a/packages/desktop/src/launcher.ts b/packages/desktop/src/launcher.ts new file mode 100644 index 0000000..a678a18 --- /dev/null +++ b/packages/desktop/src/launcher.ts @@ -0,0 +1,180 @@ +/** + * CLI launcher generation and PATH plumbing — pure helpers, no Electron imports, so + * they unit-test under plain vitest AND load in plain node: scripts/stage.mjs imports + * the built dist/launcher.js at stage time to write the launcher scripts into the + * packaged app directory's bin/. + * + * The launchers run the bundled @prismshadow/penguin-cli on the app's own Electron + * runtime as Node (ELECTRON_RUN_AS_NODE), so an installed desktop app provides a + * working `penguin` command without any system Node installation. How bin/ reaches + * PATH is per platform (see cli-install.ts and build/linux/*.tpl for the deb variant). + */ + +/** CLI entry inside the staged app directory (pnpm-deploy layout, asar off). */ +export const CLI_ENTRY_RELPATH = "node_modules/@prismshadow/penguin-cli/dist/index.js"; + +/** + * Platform executable names, fixed by electron-builder.yml: productName PenguinHarness + * (macOS bundle executable and Windows .exe) and linux.executableName penguin-harness. + */ +export const MAC_EXECUTABLE = "PenguinHarness"; +export const WIN_EXECUTABLE = "PenguinHarness.exe"; +export const LINUX_EXECUTABLE = "penguin-harness"; + +/** + * POSIX launcher, staged as `/bin/penguin` (chmod 755). Resolves its own real + * location first (PATH exposure is a symlink on macOS/deb), then finds the Electron + * executable at the platform's fixed position relative to the app directory: + * - macOS: app dir is `/Contents/Resources/app` → `../../MacOS/PenguinHarness` + * - Linux: app dir is `/resources/app` → `../../penguin-harness` + */ +export function posixLauncherScript(): string { + return `#!/bin/sh +# penguin CLI launcher (generated by packages/desktop/scripts/stage.mjs). +# Runs the bundled penguin CLI on this app's Electron runtime as Node +# (ELECTRON_RUN_AS_NODE=1) - no system Node installation required. + +# Resolve this script's real location; PATH exposure installs a symlink to it +# (readlink -f is not portable to macOS, hence the loop). +SOURCE=$0 +while [ -h "$SOURCE" ]; do + DIR=$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd) + SOURCE=$(readlink "$SOURCE") + case $SOURCE in + /*) ;; + *) SOURCE="$DIR/$SOURCE" ;; + esac +done +BIN_DIR=$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd) +APP_DIR=$(dirname "$BIN_DIR") + +CLI_ENTRY="$APP_DIR/${CLI_ENTRY_RELPATH}" +# The Electron executable sits at a fixed location relative to the app directory: +# macOS /Contents/Resources/app -> ../../MacOS/${MAC_EXECUTABLE}; +# Linux /resources/app -> ../../${LINUX_EXECUTABLE}. +for CANDIDATE in "$APP_DIR/../../MacOS/${MAC_EXECUTABLE}" "$APP_DIR/../../${LINUX_EXECUTABLE}"; do + if [ -x "$CANDIDATE" ]; then + export ELECTRON_RUN_AS_NODE=1 + exec "$CANDIDATE" "$CLI_ENTRY" "$@" + fi +done +echo "penguin: could not find the PenguinHarness runtime next to $APP_DIR" >&2 +exit 1 +`; +} + +/** + * Windows launcher, staged as `\\bin\\penguin.cmd`. `%~dp0` is this script's + * directory (with a trailing backslash): the exe sits three levels up + * (bin -> app -> resources -> install root), the CLI entry one level up in the app dir. + * CRLF line endings on purpose - cmd.exe is unreliable with bare LF scripts. + */ +export function windowsLauncherScript(): string { + return [ + "@echo off", + "rem penguin CLI launcher (generated by packages/desktop/scripts/stage.mjs).", + "rem Runs the bundled penguin CLI on this app's Electron runtime as Node.", + "setlocal", + 'set "ELECTRON_RUN_AS_NODE=1"', + `"%~dp0..\\..\\..\\${WIN_EXECUTABLE}" "%~dp0..\\${CLI_ENTRY_RELPATH.replaceAll("/", "\\")}" %*`, + "exit /b %errorlevel%", + "", + ].join("\r\n"); +} + +/** + * Single-line CJS bootstrap for the AppImage wrapper (`electron -e `): resolves + * the CLI entry relative to the mounted application (process.execPath is the Electron + * binary inside the AppImage mount, resources/app sits next to it), splices it into + * argv[1] so `node -e` argv ([execPath, ...args]) becomes the [execPath, entry, ...args] + * shape the CLI's argv parsing expects, then imports the (ESM) entry. Must not contain + * single quotes: the wrapper embeds it in a single-quoted shell string. + */ +export function appImageBootstrapJs(): string { + return ( + 'const path=require("path");' + + 'const cli=path.join(path.dirname(process.execPath),"resources","app",' + + '"node_modules","@prismshadow","penguin-cli","dist","index.js");' + + "process.argv.splice(1,0,cli);" + + "import(cli).catch((err)=>{console.error(err);process.exit(1);});" + ); +} + +/** + * Wrapper script installed to ~/.local/bin/penguin for the AppImage form, which has no + * stable install directory to symlink into: it invokes the AppImage itself as Node. + * The AppImage path is baked in at install time; the guard turns a moved/deleted + * AppImage into a clear message instead of a shell "not found". + */ +export function appImageWrapperScript(appImagePath: string): string { + if (appImagePath.includes("'")) { + // A quote would break out of the single-quoted assignment below; refuse rather + // than generate a broken (or injectable) script. + throw new Error(`AppImage path must not contain single quotes: ${appImagePath}`); + } + return `#!/bin/sh +# penguin CLI launcher (generated by the PenguinHarness desktop app). +# Runs the CLI bundled inside the AppImage by starting the AppImage's Electron +# runtime as Node (ELECTRON_RUN_AS_NODE=1). If the AppImage moves, re-run +# "Install 'penguin' Command" from the PenguinHarness application menu. +APPIMAGE_PATH='${appImagePath}' +if [ ! -x "$APPIMAGE_PATH" ]; then + echo "penguin: AppImage not found at $APPIMAGE_PATH (moved or deleted?)." >&2 + echo "penguin: re-run \\"Install 'penguin' Command\\" from the PenguinHarness menu." >&2 + exit 1 +fi +export ELECTRON_RUN_AS_NODE=1 +# The -- ends Node option parsing: without it a leading-dash argument (penguin --help) +# would be swallowed as a Node flag instead of reaching the CLI. +exec "$APPIMAGE_PATH" -e '${appImageBootstrapJs()}' -- "$@" +`; +} + +/** + * Idempotent HKCU\\Environment PATH append: returns the new value to write, or null + * when `binDir` is already present (comparison ignores case, surrounding quotes and + * trailing slashes). The existing value is preserved verbatim — only `;binDir` is + * appended — so other software's entries are never rewritten. + */ +export function mergeWindowsUserPath( + current: string | null | undefined, + binDir: string, +): string | null { + const normalize = (entry: string): string => + entry + .trim() + .replace(/^"|"$/g, "") + .replace(/[\\/]+$/, "") + .toLowerCase(); + const target = binDir.trim().replace(/[\\/]+$/, ""); + const base = (current ?? "").trim(); + const present = base + .split(";") + .map(normalize) + .some((entry) => entry !== "" && entry === normalize(target)); + if (present) return null; + if (base === "") return target; + return base.endsWith(";") ? `${base}${target}` : `${base};${target}`; +} + +export type CliInstallKind = "darwin" | "windows" | "appimage"; + +/** + * Whether (and how) this run can install the `penguin` command onto PATH. Dev runs have + * no staged bin/; deb installs already ship a /usr/bin symlink from their postinst + * script (build/linux/after-install.tpl), so only the AppImage form qualifies on Linux + * (recognized by the APPIMAGE env var its runtime sets). + */ +export function cliInstallKind(opts: { + packaged: boolean; + platform: NodeJS.Platform; + appImagePath: string | null; +}): CliInstallKind | null { + if (!opts.packaged) return null; + if (opts.platform === "darwin") return "darwin"; + if (opts.platform === "win32") return "windows"; + if (opts.platform === "linux" && opts.appImagePath !== null && opts.appImagePath !== "") { + return "appimage"; + } + return null; +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 9820061..0b73235 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -19,6 +19,9 @@ 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 { resolveWindowIcon } from "./app-icon.js"; +import { installCliCommand, maybeOfferCliInstall, currentCliInstallKind } from "./cli-install.js"; +import { installAppMenu } from "./menu.js"; import { startEmbeddedServer, stopEmbeddedServer } from "./server-process.js"; import type { EmbeddedServer } from "./server-process.js"; import { @@ -30,6 +33,10 @@ import { } from "./util.js"; app.setName("PenguinHarness"); +// Windows toasts (the web app's task-completion notifications) need the AppUserModelID +// of the installed shortcuts; electron-builder stamps them with the appId. Keep in sync +// with electron-builder.yml. +if (process.platform === "win32") app.setAppUserModelId("com.prismshadow.penguinharness"); let win: BrowserWindow | null = null; let server: EmbeddedServer | null = null; @@ -46,11 +53,15 @@ function fatal(context: string, err: unknown): void { } function createWindow(url: string): void { + // Linux window/taskbar icon (and Windows dev runs); packaged Windows uses the exe + // resources and macOS its bundle icns, so those ignore it (see app-icon.ts). + const iconPath = resolveWindowIcon(app.getAppPath(), process.platform); win = new BrowserWindow({ width: 1280, height: 860, show: false, autoHideMenuBar: true, + ...(iconPath !== null ? { icon: iconPath } : {}), webPreferences: { // The window is a plain browser: no Node, no preload — the minimal attack surface. contextIsolation: true, @@ -75,6 +86,7 @@ function createWindow(url: string): void { width: 1100, height: 800, autoHideMenuBar: true, + ...(iconPath !== null ? { icon: iconPath } : {}), // Same hardening as the main window: the preview is Agent-written, untrusted // HTML and must never get Node. webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, @@ -202,9 +214,20 @@ if (!app.requestSingleInstanceLock()) { } }); - void app - .whenReady() - .then(() => boot().catch((err) => fatal("PenguinHarness failed to start.", err))); + void app.whenReady().then(() => + (async () => { + // Standard menu plus the CLI-install entry where installing makes sense + // (packaged macOS / Windows / AppImage; deb ships /usr/bin/penguin itself). + installAppMenu({ + includeCliInstall: currentCliInstallKind() !== null, + onInstallCli: () => void installCliCommand(win), + }); + await boot(); + // First launch only: offer the 'penguin' command once; the menu entry remains. + // Skipped in smoke mode — a modal dialog would hang the automated run. + if (process.env.PENGUIN_DESKTOP_SMOKE !== "1") await maybeOfferCliInstall(win); + })().catch((err) => fatal("PenguinHarness failed to start.", err)), + ); } // --- smoke hook ------------------------------------------------------------ diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts new file mode 100644 index 0000000..aa0d2a6 --- /dev/null +++ b/packages/desktop/src/menu.ts @@ -0,0 +1,54 @@ +/** + * Application menu. Electron's default menu cannot be extended, only replaced, so the + * standard structure is rebuilt here — on macOS the Edit roles are what make clipboard + * shortcuts work inside the window, so they must be present. The one custom entry is + * "Install 'penguin' Command…" (see cli-install.ts), shown only where installing makes + * sense (packaged macOS / Windows / AppImage; deb ships /usr/bin/penguin itself). + * On Windows/Linux the window uses autoHideMenuBar, so the bar appears on Alt. + */ +import { app, Menu } from "electron"; +import type { MenuItemConstructorOptions } from "electron"; + +export const INSTALL_CLI_MENU_LABEL = "Install 'penguin' Command…"; + +export function installAppMenu(opts: { + includeCliInstall: boolean; + onInstallCli: () => void; +}): void { + const isMac = process.platform === "darwin"; + const cliItems: MenuItemConstructorOptions[] = opts.includeCliInstall + ? [{ label: INSTALL_CLI_MENU_LABEL, click: opts.onInstallCli }] + : []; + + const template: MenuItemConstructorOptions[] = []; + if (isMac) { + template.push({ + label: app.name, + submenu: [ + { role: "about" }, + ...(cliItems.length > 0 + ? ([{ type: "separator" }, ...cliItems] as MenuItemConstructorOptions[]) + : []), + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } else { + template.push({ + label: "File", + submenu: [ + ...cliItems, + ...(cliItems.length > 0 ? ([{ type: "separator" }] as MenuItemConstructorOptions[]) : []), + { role: "quit" }, + ], + }); + } + template.push({ role: "editMenu" }, { role: "viewMenu" }, { role: "windowMenu" }); + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} diff --git a/packages/desktop/test/app-icon.test.ts b/packages/desktop/test/app-icon.test.ts new file mode 100644 index 0000000..86d3694 --- /dev/null +++ b/packages/desktop/test/app-icon.test.ts @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, describe, expect, it } from "vitest"; +import { resolveWindowIcon, WINDOW_ICON_RELPATH, windowIconPathFor } from "../src/app-icon.js"; + +describe("windowIconPathFor", () => { + it("is null on macOS (window icons are ignored; the bundle icns owns the Dock)", () => { + expect(windowIconPathFor("/app", "darwin")).toBeNull(); + }); + + it("resolves build/icon.png inside the app dir on Linux and Windows", () => { + expect(windowIconPathFor("/app", "linux")).toBe(path.join("/app", ...WINDOW_ICON_RELPATH)); + expect(windowIconPathFor("C:\\app", "win32")).toBe( + path.join("C:\\app", ...WINDOW_ICON_RELPATH), + ); + }); +}); + +describe("resolveWindowIcon", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "penguin-desktop-icon-")); + afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + + it("returns the path only when the file exists", () => { + expect(resolveWindowIcon(tmp, "linux")).toBeNull(); + fs.mkdirSync(path.join(tmp, "build"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "build", "icon.png"), "png"); + expect(resolveWindowIcon(tmp, "linux")).toBe(path.join(tmp, "build", "icon.png")); + expect(resolveWindowIcon(tmp, "darwin")).toBeNull(); + }); + + it("the committed source icon resolves for a dev run of this package", () => { + const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + expect(resolveWindowIcon(pkgDir, "linux")).toBe(path.join(pkgDir, "build", "icon.png")); + }); +}); diff --git a/packages/desktop/test/launcher.test.ts b/packages/desktop/test/launcher.test.ts new file mode 100644 index 0000000..a30f2eb --- /dev/null +++ b/packages/desktop/test/launcher.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { + appImageBootstrapJs, + appImageWrapperScript, + CLI_ENTRY_RELPATH, + cliInstallKind, + LINUX_EXECUTABLE, + MAC_EXECUTABLE, + mergeWindowsUserPath, + posixLauncherScript, + WIN_EXECUTABLE, + windowsLauncherScript, +} from "../src/launcher.js"; + +describe("posixLauncherScript", () => { + const script = posixLauncherScript(); + + it("is a /bin/sh script that resolves symlinks before locating the runtime", () => { + expect(script.startsWith("#!/bin/sh\n")).toBe(true); + expect(script).toContain('while [ -h "$SOURCE" ]'); + expect(script).toContain("readlink"); + }); + + it("targets the bundled CLI entry and both platform runtime locations", () => { + expect(script).toContain(`CLI_ENTRY="$APP_DIR/${CLI_ENTRY_RELPATH}"`); + // macOS: resources/app -> ../../MacOS/; Linux: -> ../../. + expect(script).toContain(`"$APP_DIR/../../MacOS/${MAC_EXECUTABLE}"`); + expect(script).toContain(`"$APP_DIR/../../${LINUX_EXECUTABLE}"`); + }); + + it("execs as Node and forwards all arguments", () => { + expect(script).toContain("export ELECTRON_RUN_AS_NODE=1"); + expect(script).toContain('exec "$CANDIDATE" "$CLI_ENTRY" "$@"'); + }); + + it("fails with a message when no runtime is found", () => { + expect(script).toContain("exit 1"); + }); +}); + +describe("windowsLauncherScript", () => { + const script = windowsLauncherScript(); + + it("uses CRLF line endings (cmd.exe is unreliable with bare LF)", () => { + expect(script).toContain("\r\n"); + expect(script.split("\r\n").join("")).not.toContain("\n"); + }); + + it("finds the exe three levels up from bin\\ and the CLI entry in the app dir", () => { + expect(script).toContain(`"%~dp0..\\..\\..\\${WIN_EXECUTABLE}"`); + expect(script).toContain(`"%~dp0..\\${CLI_ENTRY_RELPATH.replaceAll("/", "\\")}"`); + }); + + it("runs as Node, forwards arguments and propagates the exit code", () => { + expect(script).toContain('set "ELECTRON_RUN_AS_NODE=1"'); + expect(script).toContain(" %*"); + expect(script).toContain("exit /b %errorlevel%"); + }); +}); + +describe("appImageWrapperScript", () => { + const appImage = "/home/user/Apps/penguin-desktop-linux-x86_64.AppImage"; + const script = appImageWrapperScript(appImage); + + it("bakes in the AppImage path and guards against it disappearing", () => { + expect(script).toContain(`APPIMAGE_PATH='${appImage}'`); + expect(script).toContain('if [ ! -x "$APPIMAGE_PATH" ]'); + }); + + it("invokes the AppImage as Node with the bootstrap and forwards arguments", () => { + expect(script).toContain("export ELECTRON_RUN_AS_NODE=1"); + // The -- is load-bearing: without it Node swallows leading-dash arguments + // (penguin --help) as its own flags instead of passing them to the CLI. + expect(script).toContain(`exec "$APPIMAGE_PATH" -e '${appImageBootstrapJs()}' -- "$@"`); + }); + + it("refuses paths containing single quotes (they would break the quoting)", () => { + expect(() => appImageWrapperScript("/tmp/it's.AppImage")).toThrow(/single quote/); + }); +}); + +describe("appImageBootstrapJs", () => { + const js = appImageBootstrapJs(); + + it("contains no single quotes (embedded in a single-quoted shell string)", () => { + expect(js).not.toContain("'"); + }); + + it("resolves the CLI entry relative to process.execPath and fixes argv", () => { + expect(js).toContain("process.execPath"); + expect(js).toContain('"resources","app"'); + expect(js).toContain('"penguin-cli","dist","index.js"'); + // node -e argv is [execPath, ...args]; the CLI slices argv from index 2, so the + // entry path must be spliced in at index 1. + expect(js).toContain("process.argv.splice(1,0,cli)"); + expect(js).toContain("import(cli)"); + }); + + it("is valid JavaScript", () => { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + expect(() => new Function(js)).not.toThrow(); + }); +}); + +describe("mergeWindowsUserPath", () => { + const bin = "C:\\Program Files\\PenguinHarness\\resources\\app\\bin"; + + it("appends to an existing value with a semicolon", () => { + expect(mergeWindowsUserPath("C:\\other", bin)).toBe(`C:\\other;${bin}`); + }); + + it("does not double the separator when the value ends with one", () => { + expect(mergeWindowsUserPath("C:\\other;", bin)).toBe(`C:\\other;${bin}`); + }); + + it("starts a missing or empty value with just the bin dir", () => { + expect(mergeWindowsUserPath(null, bin)).toBe(bin); + expect(mergeWindowsUserPath("", bin)).toBe(bin); + expect(mergeWindowsUserPath(" ", bin)).toBe(bin); + }); + + it("is idempotent: returns null when already present", () => { + expect(mergeWindowsUserPath(`C:\\other;${bin}`, bin)).toBeNull(); + }); + + it("matches case-insensitively and ignores quotes and trailing slashes", () => { + expect( + mergeWindowsUserPath(`c:\\program files\\penguinharness\\RESOURCES\\app\\BIN`, bin), + ).toBeNull(); + expect(mergeWindowsUserPath(`"${bin}"`, bin)).toBeNull(); + expect(mergeWindowsUserPath(`${bin}\\`, bin)).toBeNull(); + }); + + it("preserves the existing value verbatim (including %VAR% entries)", () => { + const current = "%USERPROFILE%\\bin;C:\\tools"; + expect(mergeWindowsUserPath(current, bin)).toBe(`${current};${bin}`); + }); +}); + +describe("cliInstallKind", () => { + it("is null for dev runs regardless of platform", () => { + expect(cliInstallKind({ packaged: false, platform: "darwin", appImagePath: null })).toBeNull(); + expect(cliInstallKind({ packaged: false, platform: "win32", appImagePath: null })).toBeNull(); + }); + + it("maps packaged macOS and Windows to their installers", () => { + expect(cliInstallKind({ packaged: true, platform: "darwin", appImagePath: null })).toBe( + "darwin", + ); + expect(cliInstallKind({ packaged: true, platform: "win32", appImagePath: null })).toBe( + "windows", + ); + }); + + it("on Linux only the AppImage form installs (deb ships /usr/bin/penguin itself)", () => { + expect(cliInstallKind({ packaged: true, platform: "linux", appImagePath: null })).toBeNull(); + expect(cliInstallKind({ packaged: true, platform: "linux", appImagePath: "" })).toBeNull(); + expect( + cliInstallKind({ packaged: true, platform: "linux", appImagePath: "/x/y.AppImage" }), + ).toBe("appimage"); + }); +}); diff --git a/packages/desktop/tsup.config.ts b/packages/desktop/tsup.config.ts index 788f130..ae59961 100644 --- a/packages/desktop/tsup.config.ts +++ b/packages/desktop/tsup.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/main.ts"], + // launcher.ts is a second entry on purpose: scripts/stage.mjs imports dist/launcher.js + // at stage time (plain node, no Electron) to generate the CLI launcher scripts. + entry: ["src/main.ts", "src/launcher.ts"], format: ["esm"], target: "node22", platform: "node", diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index 70573a1..a65faac 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -67,6 +67,8 @@ curl -c cookies.txt -H "Content-Type: application/json" \ | POST | /api/admin/users/:userId/password | Reset a password (invalidates all of that user's login sessions) | | DELETE | /api/admin/users/:userId | Delete a user | +In desktop mode (the server spawned by the desktop app) the whole surface answers `403` with code `desktop_single_user`: the desktop app is single-user, so user management is disabled — existing users in the data root are untouched. + ### Server Settings (admin only) | Method | Path | Description | @@ -97,7 +99,7 @@ curl -c cookies.txt -H "Content-Type: application/json" \ | POST | /api/projects/:projectId/members | Add a member: `{userId}` | | DELETE | /api/projects/:projectId/members/:userId | Remove a member | -Member writes are owner-only. +Member writes are owner-only. The member routes also answer `403 desktop_single_user` in desktop mode (see User Administration above). ### Models diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index f96b7aa..c9b31eb 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -67,6 +67,8 @@ curl -c cookies.txt -H "Content-Type: application/json" \ | POST | /api/admin/users/:userId/password | 重置密码(该用户全部登录会话失效) | | DELETE | /api/admin/users/:userId | 删除用户 | +桌面模式下(server 由桌面应用拉起)整组路由返回 `403`、错误码 `desktop_single_user`:桌面应用是单用户形态,用户管理整体停用——数据根中已有的用户不受影响。 + ### 服务端设置(仅管理员) | 方法 | 路径 | 说明 | @@ -97,7 +99,7 @@ curl -c cookies.txt -H "Content-Type: application/json" \ | POST | /api/projects/:projectId/members | 添加成员:`{userId}` | | DELETE | /api/projects/:projectId/members/:userId | 移除成员 | -成员写操作仅限 Owner。 +成员写操作仅限 Owner。成员路由在桌面模式下同样返回 `403 desktop_single_user`(见上文「用户管理」)。 ### 模型 diff --git a/packages/server/src/http/routes/admin.ts b/packages/server/src/http/routes/admin.ts index 73f6a5d..522436f 100644 --- a/packages/server/src/http/routes/admin.ts +++ b/packages/server/src/http/routes/admin.ts @@ -1,10 +1,12 @@ /** - * Admin user-backend routes: only the built-in admin can use these (403 for non-admins). + * Admin user-backend routes: only the built-in admin can use these (403 for non-admins), + * and desktop mode rejects the whole surface (single-user; 403 `desktop_single_user`). * GET|POST /api/admin/users, POST /api/admin/users/:userId/password, DELETE /api/admin/users/:userId. */ import { Hono } from "hono"; import type { AdminUserCreateResponse, AdminUsersResponse } from "../../api/types.js"; import { HttpError } from "../errors.js"; +import { rejectInDesktopMode } from "./desktop.js"; import type { AppEnv } from "../../auth/middleware.js"; import { pathParam, readJson, requireString } from "../validate.js"; import type { AppDeps } from "../../app.js"; @@ -12,6 +14,7 @@ import type { AppDeps } from "../../app.js"; export function adminUsersRoutes(deps: AppDeps): Hono { const app = new Hono(); + app.use("*", rejectInDesktopMode(deps)); app.use("*", async (c, next) => { if (!c.var.user.isAdmin) { throw new HttpError(403, "admin_required", "Only an admin can perform this operation."); diff --git a/packages/server/src/http/routes/desktop.ts b/packages/server/src/http/routes/desktop.ts index 2ee692f..572a35b 100644 --- a/packages/server/src/http/routes/desktop.ts +++ b/packages/server/src/http/routes/desktop.ts @@ -1,15 +1,37 @@ /** - * Desktop-mode routes: POST /api/desktop/shutdown. + * Desktop-mode routes: POST /api/desktop/shutdown, plus the shared desktop-mode guard + * that turns off multi-user surfaces (see rejectInDesktopMode). * - * 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. + * The shutdown route is authenticated by the shell's Bearer token, not the cookie + * session (the shell holds no cookie), so it 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 type { MiddlewareHandler } from "hono"; import { HttpError } from "../errors.js"; import type { AppDeps } from "../../app.js"; +/** + * Guard for user-management surfaces (admin users, Project members): the desktop app is + * single-user, so the whole surface answers 403 with a dedicated code rather than being + * unmounted — a stray client gets a clear, localizable error instead of a 404. Existing + * users and memberships in the data root are untouched; only the management routes are + * closed while the server runs under the desktop shell. + */ +export function rejectInDesktopMode(deps: AppDeps): MiddlewareHandler { + return async (_c, next) => { + if (deps.desktop !== null) { + throw new HttpError( + 403, + "desktop_single_user", + "User management is disabled in the desktop app (single-user mode).", + ); + } + await next(); + }; +} + /** Delay between answering 202 and starting shutdown: lets the response flush. */ const SHUTDOWN_DELAY_MS = 50; diff --git a/packages/server/src/http/routes/members.ts b/packages/server/src/http/routes/members.ts index f747781..ff75c6c 100644 --- a/packages/server/src/http/routes/members.ts +++ b/packages/server/src/http/routes/members.ts @@ -2,16 +2,20 @@ * Member authorization routes: * GET|POST /api/projects/:p/members, DELETE /api/projects/:p/members/:userId. * Reading requires access; adding/removing is owner-only (validated inside the service). + * Desktop mode rejects the whole surface (single-user; 403 `desktop_single_user`). */ import { Hono } from "hono"; import type { MemberAddResponse, MembersResponse } from "../../api/types.js"; import type { AppEnv } from "../../auth/middleware.js"; +import { rejectInDesktopMode } from "./desktop.js"; import { pathParam, readJson, requireString, requireValidId } from "../validate.js"; import type { AppDeps } from "../../app.js"; export function membersRoutes(deps: AppDeps): Hono { const app = new Hono(); + app.use("*", rejectInDesktopMode(deps)); + app.get("/", (c) => { // Defensive id validation (FD-4). const members = deps.projectService.listMembers( diff --git a/packages/server/test/desktop.test.ts b/packages/server/test/desktop.test.ts index 1b63680..53a4ab7 100644 --- a/packages/server/test/desktop.test.ts +++ b/packages/server/test/desktop.test.ts @@ -1,10 +1,11 @@ /** * Desktop mode: one-shot desktop-login, Bearer-token shutdown, desktopMode in /api/me, - * and the desktop-session password change without oldPassword. + * the desktop-session password change without oldPassword, and the single-user guard + * closing the user-management and Project-member surfaces. */ import { describe, expect, it } from "vitest"; import { apiClient, createTestApp, loginAdmin } from "./helpers.js"; -import type { MeResponse } from "../src/api/types.js"; +import type { ErrorBody, MeResponse } from "../src/api/types.js"; const TOKEN = "test-desktop-token"; @@ -119,6 +120,75 @@ describe("desktop shutdown endpoint", () => { }); }); +describe("desktop single-user mode", () => { + async function expectSingleUser403(res: Response): Promise { + expect(res.status).toBe(403); + const body = (await res.json()) as ErrorBody; + expect(body.error.code).toBe("desktop_single_user"); + } + + it("rejects the whole admin-users surface with desktop_single_user", async () => { + const t = await desktopApp(); + try { + // The seeded admin signed in through the regular login form: even a fully + // authorized admin session gets the dedicated 403, not admin_required. + const admin = await loginAdmin(t.app); + const api = apiClient(t.app, admin.cookie); + await expectSingleUser403(await api.get("/api/admin/users")); + await expectSingleUser403( + await api.post("/api/admin/users", { userId: "eve", password: "password-123" }), + ); + await expectSingleUser403( + await api.post("/api/admin/users/admin/password", { password: "password-456" }), + ); + await expectSingleUser403( + await t.app.request("/api/admin/users/eve", { + method: "DELETE", + headers: { cookie: admin.cookie }, + }), + ); + // No user was created by the rejected POST. + expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(1); + } finally { + await t.cleanup(); + } + }); + + it("rejects Project member management (reads and writes) with desktop_single_user", async () => { + const t = await desktopApp(); + try { + const admin = await loginAdmin(t.app); + const api = apiClient(t.app, admin.cookie); + await expectSingleUser403(await api.get("/api/projects/default_project/members")); + await expectSingleUser403( + await api.post("/api/projects/default_project/members", { userId: "eve" }), + ); + await expectSingleUser403( + await t.app.request("/api/projects/default_project/members/eve", { + method: "DELETE", + headers: { cookie: admin.cookie }, + }), + ); + } finally { + await t.cleanup(); + } + }); + + it("leaves both surfaces working on a normal multi-user server", async () => { + const t = await createTestApp(); + try { + const admin = await loginAdmin(t.app); + const api = apiClient(t.app, admin.cookie); + const users = await api.get("/api/admin/users"); + expect(users.status).toBe(200); + const members = await api.get("/api/projects/default_project/members"); + expect(members.status).toBe(200); + } finally { + await t.cleanup(); + } + }); +}); + describe("desktop-session password change", () => { async function desktopCookie(t: Awaited>): Promise { const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`); diff --git a/packages/web/src/components/layout/app-layout.tsx b/packages/web/src/components/layout/app-layout.tsx index b2b22a8..6cfb0bc 100644 --- a/packages/web/src/components/layout/app-layout.tsx +++ b/packages/web/src/components/layout/app-layout.tsx @@ -12,6 +12,7 @@ import { useVersionInfo } from "../../lib/use-version-info"; import { useAuth } from "../../state/auth"; import { useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; +import { useCompletionNotifications } from "../../state/use-completion-notifications"; import { Drawer } from "../ui/drawer"; import { GlyphIcon } from "../ui/glyph-icon"; import { NAV_ICONS } from "../ui/icons"; @@ -167,6 +168,9 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) { export function AppLayout() { const { user, desktopMode } = useAuth(); + // Desktop shell only (gated inside): system notification when a task finishes while + // the window is unfocused. + useCompletionNotifications(); const [drawerOpen, setDrawerOpen] = useState(false); const [changePasswordOpen, setChangePasswordOpen] = useState(false); // Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from. diff --git a/packages/web/src/components/layout/project-dialogs.tsx b/packages/web/src/components/layout/project-dialogs.tsx index 9aa2340..bb512b9 100644 --- a/packages/web/src/components/layout/project-dialogs.tsx +++ b/packages/web/src/components/layout/project-dialogs.tsx @@ -172,7 +172,7 @@ export function CreateProjectDialog({ * deletion (owner); members see the name and member list read-only. */ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { - const { user } = useAuth(); + const { user, desktopMode } = useAuth(); const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject(); const [members, setMembers] = useState(null); const [newMemberId, setNewMemberId] = useState(""); @@ -196,14 +196,18 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos setConfirmDelete(false); setName(savedName); setNameError(undefined); - api - .listMembers(projectId) - .then((res) => setMembers(res.members)) - .catch((e: unknown) => setLoadError(apiErrorText(e))); + // Desktop mode is single-user: the member section is hidden below and the server + // rejects the member routes (desktop_single_user), so nothing is fetched. + if (!desktopMode) { + api + .listMembers(projectId) + .then((res) => setMembers(res.members)) + .catch((e: unknown) => setLoadError(apiErrorText(e))); + } // savedName is read at open time only: retyping in the field must not be clobbered by a // list refresh, and reopening the dialog re-seeds it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, projectId]); + }, [open, projectId, desktopMode]); if (!currentProject || !projectId) return null; @@ -306,76 +310,85 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos

{projectId}

-
-

{S.project.members}

- {loadError ? ( -

{loadError}

- ) : members === null ? ( -

{S.common.loading}

- ) : ( - // Member permission table: username / role / actions; cells never wrap. - // Last row (owner only) = add member: small username input + add button (new members are always the member role). -
- - - - - - - - - - {members.map((m) => ( - - - - + + )} + +
- {S.common.username} - {S.common.role} - {S.common.actions} -
{m.userId} - {m.role} - - {isOwner && m.role !== "owner" && m.userId !== user?.userId && ( -
+
+ )} +
+ )} diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index 557e2cc..e714f7f 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -1119,8 +1119,10 @@ export function Sidebar({ )} )} - {/* User management is visible only to admins (the page route also has its own guard as a fallback). */} - {user?.isAdmin && ( + {/* User management is visible only to admins (the page route also has its own + guard as a fallback), and never in desktop mode: the desktop app is + single-user and the server rejects the routes (desktop_single_user). */} + {user?.isAdmin && !desktopMode && (