feat(desktop,server,web): app icons, completion notifications, single-user mode, bundled penguin CLI (#226)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-06 20:36:50 +08:00
committed by GitHub
parent 91f3292ab5
commit 082ab08e0b
34 changed files with 1404 additions and 100 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -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
@@ -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 <name> <path>: '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
+18
View File
@@ -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
+1
View File
@@ -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:*"
},
+72
View File
@@ -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/<N>x<N>.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(
`<style>html,body{margin:0;padding:0;background:transparent}` +
`img{display:block;width:${size}px;height:${size}px}</style>` +
`<img src="${svgDataUrl}">`,
);
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();
}
+47 -4
View File
@@ -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);
+27
View File
@@ -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;
}
+212
View File
@@ -0,0 +1,212 @@
/**
* "Install 'penguin' Command" — PATH exposure for the bundled CLI launcher
* (<app>/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 → <app>/bin/penguin; on permission errors,
* escalate ONCE via osascript "with administrator privileges".
* - Windows: append <app>\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<void> {
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<void> {
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<void> {
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<void> {
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);
}
+180
View File
@@ -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 `<app>/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 `<bundle>/Contents/Resources/app` → `../../MacOS/PenguinHarness`
* - Linux: app dir is `<install>/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 <bundle>/Contents/Resources/app -> ../../MacOS/${MAC_EXECUTABLE};
# Linux <install>/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 `<app>\\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 <this>`): 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;
}
+26 -3
View File
@@ -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 ------------------------------------------------------------
+54
View File
@@ -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));
}
+37
View File
@@ -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"));
});
});
+162
View File
@@ -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/<executable>; Linux: -> ../../<executableName>.
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");
});
});
+3 -1
View File
@@ -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",