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:
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
|
||||||
@@ -24,6 +24,10 @@ nodeGypRebuild: false
|
|||||||
|
|
||||||
mac:
|
mac:
|
||||||
category: public.app-category.developer-tools
|
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.
|
# No Developer ID yet (M4): identity null skips codesign instead of failing.
|
||||||
identity: null
|
identity: null
|
||||||
# Version-less artifact names, following the CLI bundles (penguin-darwin-arm64.tar.gz):
|
# Version-less artifact names, following the CLI bundles (penguin-darwin-arm64.tar.gz):
|
||||||
@@ -38,6 +42,9 @@ mac:
|
|||||||
arch: [arm64, x64]
|
arch: [arm64, x64]
|
||||||
|
|
||||||
win:
|
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}
|
artifactName: penguin-desktop-win32-${arch}.${ext}
|
||||||
target:
|
target:
|
||||||
- target: nsis
|
- target: nsis
|
||||||
@@ -54,6 +61,9 @@ nsis:
|
|||||||
allowToChangeInstallationDirectory: true
|
allowToChangeInstallationDirectory: true
|
||||||
|
|
||||||
linux:
|
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.
|
# The scoped package name is not a valid executable file name.
|
||||||
executableName: penguin-harness
|
executableName: penguin-harness
|
||||||
# ${arch} expands to each Linux target's own convention — x86_64 for AppImage, amd64
|
# ${arch} expands to each Linux target's own convention — x86_64 for AppImage, amd64
|
||||||
@@ -68,3 +78,11 @@ linux:
|
|||||||
arch: [x64]
|
arch: [x64]
|
||||||
- target: deb
|
- target: deb
|
||||||
arch: [x64]
|
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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"pack:win": "node scripts/stage.mjs && electron-builder --win"
|
"pack:win": "node scripts/stage.mjs && electron-builder --win"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prismshadow/penguin-cli": "workspace:*",
|
||||||
"@prismshadow/penguin-core": "workspace:*",
|
"@prismshadow/penguin-core": "workspace:*",
|
||||||
"@prismshadow/penguin-server": "workspace:*"
|
"@prismshadow/penguin-server": "workspace:*"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -2,20 +2,25 @@
|
|||||||
* Assemble the self-contained app directory electron-builder packs (stage/app).
|
* Assemble the self-contained app directory electron-builder packs (stage/app).
|
||||||
*
|
*
|
||||||
* `pnpm deploy --prod` materializes this package plus its production dependency tree —
|
* `pnpm deploy --prod` materializes this package plus its production dependency tree —
|
||||||
* including the workspace packages — into a portable directory whose symlinks all stay
|
* including the workspace packages (@prismshadow/penguin-cli among them, so the staged
|
||||||
* inside it (verified: the server boots from the deploy dir as-is). On top of that:
|
* 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;
|
* - 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
|
* - copy the web build to `node_modules/@prismshadow/penguin-server/web-dist`, the npm
|
||||||
* package layout the server's static-hosting lookup checks first;
|
* 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
|
* - 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.
|
* 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 { execFileSync } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
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 pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const repoRoot = path.resolve(pkgDir, "..", "..");
|
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");
|
const serverPkg = path.join(appDir, "node_modules", "@prismshadow", "penguin-server");
|
||||||
fs.cpSync(webDist, path.join(serverPkg, "web-dist"), { recursive: true, dereference: true });
|
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 });
|
fs.mkdirSync(path.join(stageDir, "minigit"), { recursive: true });
|
||||||
|
|
||||||
console.log("[stage] done:", appDir);
|
console.log("[stage] done:", appDir);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ import path from "node:path";
|
|||||||
import { app, BrowserWindow, dialog, shell } from "electron";
|
import { app, BrowserWindow, dialog, shell } from "electron";
|
||||||
import { resolveRoot } from "@prismshadow/penguin-core";
|
import { resolveRoot } from "@prismshadow/penguin-core";
|
||||||
import { liveServerLock } from "@prismshadow/penguin-server/lock";
|
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 { startEmbeddedServer, stopEmbeddedServer } from "./server-process.js";
|
||||||
import type { EmbeddedServer } from "./server-process.js";
|
import type { EmbeddedServer } from "./server-process.js";
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +33,10 @@ import {
|
|||||||
} from "./util.js";
|
} from "./util.js";
|
||||||
|
|
||||||
app.setName("PenguinHarness");
|
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 win: BrowserWindow | null = null;
|
||||||
let server: EmbeddedServer | null = null;
|
let server: EmbeddedServer | null = null;
|
||||||
@@ -46,11 +53,15 @@ function fatal(context: string, err: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(url: string): 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({
|
win = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 860,
|
height: 860,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
...(iconPath !== null ? { icon: iconPath } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
// The window is a plain browser: no Node, no preload — the minimal attack surface.
|
// The window is a plain browser: no Node, no preload — the minimal attack surface.
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -75,6 +86,7 @@ function createWindow(url: string): void {
|
|||||||
width: 1100,
|
width: 1100,
|
||||||
height: 800,
|
height: 800,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
...(iconPath !== null ? { icon: iconPath } : {}),
|
||||||
// Same hardening as the main window: the preview is Agent-written, untrusted
|
// Same hardening as the main window: the preview is Agent-written, untrusted
|
||||||
// HTML and must never get Node.
|
// HTML and must never get Node.
|
||||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||||
@@ -202,9 +214,20 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
void app
|
void app.whenReady().then(() =>
|
||||||
.whenReady()
|
(async () => {
|
||||||
.then(() => boot().catch((err) => fatal("PenguinHarness failed to start.", err)));
|
// 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 ------------------------------------------------------------
|
// --- smoke hook ------------------------------------------------------------
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { defineConfig } from "tsup";
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
export default defineConfig({
|
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"],
|
format: ["esm"],
|
||||||
target: "node22",
|
target: "node22",
|
||||||
platform: "node",
|
platform: "node",
|
||||||
|
|||||||
@@ -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) |
|
| 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 |
|
| 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)
|
### Server Settings (admin only)
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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}` |
|
| POST | /api/projects/:projectId/members | Add a member: `{userId}` |
|
||||||
| DELETE | /api/projects/:projectId/members/:userId | Remove a member |
|
| 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
|
### Models
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ curl -c cookies.txt -H "Content-Type: application/json" \
|
|||||||
| POST | /api/admin/users/:userId/password | 重置密码(该用户全部登录会话失效) |
|
| POST | /api/admin/users/:userId/password | 重置密码(该用户全部登录会话失效) |
|
||||||
| DELETE | /api/admin/users/:userId | 删除用户 |
|
| 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}` |
|
| POST | /api/projects/:projectId/members | 添加成员:`{userId}` |
|
||||||
| DELETE | /api/projects/:projectId/members/:userId | 移除成员 |
|
| DELETE | /api/projects/:projectId/members/:userId | 移除成员 |
|
||||||
|
|
||||||
成员写操作仅限 Owner。
|
成员写操作仅限 Owner。成员路由在桌面模式下同样返回 `403 desktop_single_user`(见上文「用户管理」)。
|
||||||
|
|
||||||
### 模型
|
### 模型
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* GET|POST /api/admin/users, POST /api/admin/users/:userId/password, DELETE /api/admin/users/:userId.
|
||||||
*/
|
*/
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { AdminUserCreateResponse, AdminUsersResponse } from "../../api/types.js";
|
import type { AdminUserCreateResponse, AdminUsersResponse } from "../../api/types.js";
|
||||||
import { HttpError } from "../errors.js";
|
import { HttpError } from "../errors.js";
|
||||||
|
import { rejectInDesktopMode } from "./desktop.js";
|
||||||
import type { AppEnv } from "../../auth/middleware.js";
|
import type { AppEnv } from "../../auth/middleware.js";
|
||||||
import { pathParam, readJson, requireString } from "../validate.js";
|
import { pathParam, readJson, requireString } from "../validate.js";
|
||||||
import type { AppDeps } from "../../app.js";
|
import type { AppDeps } from "../../app.js";
|
||||||
@@ -12,6 +14,7 @@ import type { AppDeps } from "../../app.js";
|
|||||||
export function adminUsersRoutes(deps: AppDeps): Hono<AppEnv> {
|
export function adminUsersRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
app.use("*", rejectInDesktopMode(deps));
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
if (!c.var.user.isAdmin) {
|
if (!c.var.user.isAdmin) {
|
||||||
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
|
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
|
||||||
|
|||||||
@@ -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
|
* The shutdown route is authenticated by the shell's Bearer token, not the cookie
|
||||||
* cookie), so this mounts OUTSIDE authMiddleware and only when desktop mode is enabled.
|
* session (the shell holds no cookie), so it mounts OUTSIDE authMiddleware and only
|
||||||
* Responds 202 first, then triggers the graceful shutdown a beat later so the response
|
* when desktop mode is enabled. Responds 202 first, then triggers the graceful
|
||||||
* isn't cut off by the closing listener.
|
* shutdown a beat later so the response isn't cut off by the closing listener.
|
||||||
*/
|
*/
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { HttpError } from "../errors.js";
|
import { HttpError } from "../errors.js";
|
||||||
import type { AppDeps } from "../../app.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. */
|
/** Delay between answering 202 and starting shutdown: lets the response flush. */
|
||||||
const SHUTDOWN_DELAY_MS = 50;
|
const SHUTDOWN_DELAY_MS = 50;
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
* Member authorization routes:
|
* Member authorization routes:
|
||||||
* GET|POST /api/projects/:p/members, DELETE /api/projects/:p/members/:userId.
|
* GET|POST /api/projects/:p/members, DELETE /api/projects/:p/members/:userId.
|
||||||
* Reading requires access; adding/removing is owner-only (validated inside the service).
|
* 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 { Hono } from "hono";
|
||||||
import type { MemberAddResponse, MembersResponse } from "../../api/types.js";
|
import type { MemberAddResponse, MembersResponse } from "../../api/types.js";
|
||||||
import type { AppEnv } from "../../auth/middleware.js";
|
import type { AppEnv } from "../../auth/middleware.js";
|
||||||
|
import { rejectInDesktopMode } from "./desktop.js";
|
||||||
import { pathParam, readJson, requireString, requireValidId } from "../validate.js";
|
import { pathParam, readJson, requireString, requireValidId } from "../validate.js";
|
||||||
import type { AppDeps } from "../../app.js";
|
import type { AppDeps } from "../../app.js";
|
||||||
|
|
||||||
export function membersRoutes(deps: AppDeps): Hono<AppEnv> {
|
export function membersRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
app.use("*", rejectInDesktopMode(deps));
|
||||||
|
|
||||||
app.get("/", (c) => {
|
app.get("/", (c) => {
|
||||||
// Defensive id validation (FD-4).
|
// Defensive id validation (FD-4).
|
||||||
const members = deps.projectService.listMembers(
|
const members = deps.projectService.listMembers(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Desktop mode: one-shot desktop-login, Bearer-token shutdown, desktopMode in /api/me,
|
* 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 { describe, expect, it } from "vitest";
|
||||||
import { apiClient, createTestApp, loginAdmin } from "./helpers.js";
|
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";
|
const TOKEN = "test-desktop-token";
|
||||||
|
|
||||||
@@ -119,6 +120,75 @@ describe("desktop shutdown endpoint", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("desktop single-user mode", () => {
|
||||||
|
async function expectSingleUser403(res: Response): Promise<void> {
|
||||||
|
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", () => {
|
describe("desktop-session password change", () => {
|
||||||
async function desktopCookie(t: Awaited<ReturnType<typeof desktopApp>>): Promise<string> {
|
async function desktopCookie(t: Awaited<ReturnType<typeof desktopApp>>): Promise<string> {
|
||||||
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
|
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useVersionInfo } from "../../lib/use-version-info";
|
|||||||
import { useAuth } from "../../state/auth";
|
import { useAuth } from "../../state/auth";
|
||||||
import { useProject } from "../../state/project";
|
import { useProject } from "../../state/project";
|
||||||
import { useSessions } from "../../state/sessions";
|
import { useSessions } from "../../state/sessions";
|
||||||
|
import { useCompletionNotifications } from "../../state/use-completion-notifications";
|
||||||
import { Drawer } from "../ui/drawer";
|
import { Drawer } from "../ui/drawer";
|
||||||
import { GlyphIcon } from "../ui/glyph-icon";
|
import { GlyphIcon } from "../ui/glyph-icon";
|
||||||
import { NAV_ICONS } from "../ui/icons";
|
import { NAV_ICONS } from "../ui/icons";
|
||||||
@@ -167,6 +168,9 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
|
|||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { user, desktopMode } = useAuth();
|
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 [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||||
// Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from.
|
// Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from.
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function CreateProjectDialog({
|
|||||||
* deletion (owner); members see the name and member list read-only.
|
* deletion (owner); members see the name and member list read-only.
|
||||||
*/
|
*/
|
||||||
export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const { user } = useAuth();
|
const { user, desktopMode } = useAuth();
|
||||||
const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject();
|
const { currentProject, setCurrentProjectId, projects, reloadProjects } = useProject();
|
||||||
const [members, setMembers] = useState<MemberInfo[] | null>(null);
|
const [members, setMembers] = useState<MemberInfo[] | null>(null);
|
||||||
const [newMemberId, setNewMemberId] = useState("");
|
const [newMemberId, setNewMemberId] = useState("");
|
||||||
@@ -196,14 +196,18 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
|||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
setName(savedName);
|
setName(savedName);
|
||||||
setNameError(undefined);
|
setNameError(undefined);
|
||||||
api
|
// Desktop mode is single-user: the member section is hidden below and the server
|
||||||
.listMembers(projectId)
|
// rejects the member routes (desktop_single_user), so nothing is fetched.
|
||||||
.then((res) => setMembers(res.members))
|
if (!desktopMode) {
|
||||||
.catch((e: unknown) => setLoadError(apiErrorText(e)));
|
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
|
// 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.
|
// list refresh, and reopening the dialog re-seeds it.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, projectId]);
|
}, [open, projectId, desktopMode]);
|
||||||
|
|
||||||
if (!currentProject || !projectId) return null;
|
if (!currentProject || !projectId) return null;
|
||||||
|
|
||||||
@@ -306,76 +310,85 @@ export function ProjectSettingsDialog({ open, onClose }: { open: boolean; onClos
|
|||||||
<p className="mt-1 font-mono text-xs text-gray-400">{projectId}</p>
|
<p className="mt-1 font-mono text-xs text-gray-400">{projectId}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Member management does not exist in the single-user desktop app. */}
|
||||||
<p className="mb-2 text-xs font-medium text-gray-500">{S.project.members}</p>
|
{!desktopMode && (
|
||||||
{loadError ? (
|
<div>
|
||||||
<p className="text-xs text-red-600 dark:text-red-400">{loadError}</p>
|
<p className="mb-2 text-xs font-medium text-gray-500">{S.project.members}</p>
|
||||||
) : members === null ? (
|
{loadError ? (
|
||||||
<p className="text-xs text-gray-400">{S.common.loading}</p>
|
<p className="text-xs text-red-600 dark:text-red-400">{loadError}</p>
|
||||||
) : (
|
) : members === null ? (
|
||||||
// Member permission table: username / role / actions; cells never wrap.
|
<p className="text-xs text-gray-400">{S.common.loading}</p>
|
||||||
// Last row (owner only) = add member: small username input + add button (new members are always the member role).
|
) : (
|
||||||
<div className="overflow-x-auto rounded-md border border-gray-200 dark:border-gray-800">
|
// Member permission table: username / role / actions; cells never wrap.
|
||||||
<table className="w-full text-xs">
|
// Last row (owner only) = add member: small username input + add button (new members are always the member role).
|
||||||
<thead>
|
<div className="overflow-x-auto rounded-md border border-gray-200 dark:border-gray-800">
|
||||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
|
<table className="w-full text-xs">
|
||||||
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
|
<thead>
|
||||||
{S.common.username}
|
<tr className="border-b border-gray-100 bg-gray-50 text-left text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
|
||||||
</th>
|
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
|
||||||
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">{S.common.role}</th>
|
{S.common.username}
|
||||||
<th className="w-20 whitespace-nowrap px-2.5 py-1.5 text-right font-medium">
|
</th>
|
||||||
{S.common.actions}
|
<th className="whitespace-nowrap px-2.5 py-1.5 font-medium">
|
||||||
</th>
|
{S.common.role}
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
<th className="w-20 whitespace-nowrap px-2.5 py-1.5 text-right font-medium">
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800/60">
|
{S.common.actions}
|
||||||
{members.map((m) => (
|
</th>
|
||||||
<tr key={m.userId}>
|
</tr>
|
||||||
<td className="whitespace-nowrap px-2.5 py-1.5">{m.userId}</td>
|
</thead>
|
||||||
<td className="whitespace-nowrap px-2.5 py-1.5">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800/60">
|
||||||
<Badge tone="gray">{m.role}</Badge>
|
{members.map((m) => (
|
||||||
</td>
|
<tr key={m.userId}>
|
||||||
<td className="whitespace-nowrap px-2.5 py-1 text-right">
|
<td className="whitespace-nowrap px-2.5 py-1.5">{m.userId}</td>
|
||||||
{isOwner && m.role !== "owner" && m.userId !== user?.userId && (
|
<td className="whitespace-nowrap px-2.5 py-1.5">
|
||||||
<Button size="sm" variant="ghost" onClick={() => void doRemove(m.userId)}>
|
<Badge tone="gray">{m.role}</Badge>
|
||||||
{S.project.removeMember}
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2.5 py-1 text-right">
|
||||||
|
{isOwner && m.role !== "owner" && m.userId !== user?.userId && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void doRemove(m.userId)}
|
||||||
|
>
|
||||||
|
{S.project.removeMember}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{isOwner && (
|
||||||
|
<tr>
|
||||||
|
<td className="px-2.5 py-1.5">
|
||||||
|
<Input
|
||||||
|
placeholder={S.common.username}
|
||||||
|
size="sm"
|
||||||
|
value={newMemberId}
|
||||||
|
onChange={(e) => setNewMemberId(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void addMember();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2.5 py-1.5">
|
||||||
|
<Badge tone="gray">member</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-2.5 py-1 text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!newMemberId.trim()}
|
||||||
|
onClick={() => void addMember()}
|
||||||
|
>
|
||||||
|
{S.project.addMember}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
)}
|
||||||
))}
|
</tbody>
|
||||||
{isOwner && (
|
</table>
|
||||||
<tr>
|
</div>
|
||||||
<td className="px-2.5 py-1.5">
|
)}
|
||||||
<Input
|
</div>
|
||||||
placeholder={S.common.username}
|
)}
|
||||||
size="sm"
|
|
||||||
value={newMemberId}
|
|
||||||
onChange={(e) => setNewMemberId(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") void addMember();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-2.5 py-1.5">
|
|
||||||
<Badge tone="gray">member</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="whitespace-nowrap px-2.5 py-1 text-right">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={!newMemberId.trim()}
|
|
||||||
onClick={() => void addMember()}
|
|
||||||
>
|
|
||||||
{S.project.addMember}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChatDefaultsSection projectId={projectId} isOwner={isOwner} />
|
<ChatDefaultsSection projectId={projectId} isOwner={isOwner} />
|
||||||
|
|
||||||
|
|||||||
@@ -1119,8 +1119,10 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
|
{/* User management is visible only to admins (the page route also has its own
|
||||||
{user?.isAdmin && (
|
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 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={menuItemClass}
|
className={menuItemClass}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Modal } from "../../components/ui/modal";
|
|||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
useDocumentTitle(S.admin.users);
|
useDocumentTitle(S.admin.users);
|
||||||
const { user } = useAuth();
|
const { user, desktopMode } = useAuth();
|
||||||
const [users, setUsers] = useState<UserInfo[] | null>(null);
|
const [users, setUsers] = useState<UserInfo[] | null>(null);
|
||||||
const [listError, setListError] = useState<string | null>(null);
|
const [listError, setListError] = useState<string | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@@ -40,11 +40,13 @@ export function AdminUsersPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.isAdmin) void reload();
|
if (user?.isAdmin && !desktopMode) void reload();
|
||||||
}, [user?.isAdmin, reload]);
|
}, [user?.isAdmin, desktopMode, reload]);
|
||||||
|
|
||||||
// Route guard fallback: non-admins are redirected back to the chat page (the sidebar has no entry point anyway).
|
// Route guard fallback: non-admins are redirected back to the chat page (the sidebar has
|
||||||
if (user && !user.isAdmin) return <Navigate to="/chat" replace />;
|
// no entry point anyway); same for desktop mode, where the app is single-user and the
|
||||||
|
// server rejects the admin-user routes (desktop_single_user).
|
||||||
|
if (user && (!user.isAdmin || desktopMode)) return <Navigate to="/chat" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto p-4 md:p-6">
|
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Task-completion detection over the tracked Session list (pure logic, node-testable —
|
||||||
|
* the desktop notification hook in state/use-completion-notifications.ts drives it).
|
||||||
|
*
|
||||||
|
* A Session is "active" while running or compacting; a completion is the transition of
|
||||||
|
* a KNOWN active Session to idle. First observations never fire — a freshly loaded list
|
||||||
|
* only states where sessions already are, it doesn't witness a finish. Because the
|
||||||
|
* transition flips the stored state, each run fires at most once (the per-session+run
|
||||||
|
* dedupe); on top of that, a short per-session cooldown swallows the flap where a stale
|
||||||
|
* list snapshot racing the live SSE flip briefly resurrects "running" and would
|
||||||
|
* otherwise double-notify the same finish.
|
||||||
|
*/
|
||||||
|
import type { SessionStatus } from "@prismshadow/penguin-server/api";
|
||||||
|
|
||||||
|
export interface ObservedSession {
|
||||||
|
sessionId: string;
|
||||||
|
status: SessionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a status counts as an in-flight run. */
|
||||||
|
function isActive(status: SessionStatus): boolean {
|
||||||
|
return status === "running" || status === "compacting";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum spacing between two completion firings of the same Session (see header). */
|
||||||
|
export const COMPLETION_COOLDOWN_MS = 10_000;
|
||||||
|
|
||||||
|
export interface CompletionTracker {
|
||||||
|
/**
|
||||||
|
* Feed the current Session list; returns the sessionIds whose run just completed.
|
||||||
|
* `now` is injectable for tests (defaults to Date.now()).
|
||||||
|
*/
|
||||||
|
observe(list: readonly ObservedSession[], now?: number): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCompletionTracker(
|
||||||
|
cooldownMs: number = COMPLETION_COOLDOWN_MS,
|
||||||
|
): CompletionTracker {
|
||||||
|
interface Entry {
|
||||||
|
active: boolean;
|
||||||
|
lastFiredAt: number | null;
|
||||||
|
}
|
||||||
|
const entries = new Map<string, Entry>();
|
||||||
|
return {
|
||||||
|
observe(list, now = Date.now()) {
|
||||||
|
const completed: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const s of list) {
|
||||||
|
// Defensive: the list is deduplicated upstream, but only the first occurrence
|
||||||
|
// of an id may drive the state machine.
|
||||||
|
if (seen.has(s.sessionId)) continue;
|
||||||
|
seen.add(s.sessionId);
|
||||||
|
const active = isActive(s.status);
|
||||||
|
const prev = entries.get(s.sessionId);
|
||||||
|
if (prev === undefined) {
|
||||||
|
entries.set(s.sessionId, { active, lastFiredAt: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (prev.active && !active) {
|
||||||
|
if (prev.lastFiredAt === null || now - prev.lastFiredAt >= cooldownMs) {
|
||||||
|
completed.push(s.sessionId);
|
||||||
|
prev.lastFiredAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev.active = active;
|
||||||
|
}
|
||||||
|
// Prune vanished sessions (deleted / Project switch): reappearing later counts as
|
||||||
|
// a fresh first observation, which never fires.
|
||||||
|
for (const id of [...entries.keys()]) {
|
||||||
|
if (!seen.has(id)) entries.delete(id);
|
||||||
|
}
|
||||||
|
return completed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -92,6 +92,13 @@ export const en: Strings = {
|
|||||||
adminOnly: "Only an administrator can run the update from here.",
|
adminOnly: "Only an administrator can run the update from here.",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Desktop task-completion notifications (window unfocused; desktop-shell sessions only). */
|
||||||
|
notify: {
|
||||||
|
taskCompleteTitle: "Task completed",
|
||||||
|
/** `session` is the Session title (defaultSessionTitle when unnamed). */
|
||||||
|
taskCompleteBody: (session: string): string => `"${session}" has finished — click to view`,
|
||||||
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
save: "Save",
|
save: "Save",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
@@ -1143,6 +1150,7 @@ Scenarios:
|
|||||||
password_mismatch: "The current password is incorrect.",
|
password_mismatch: "The current password is incorrect.",
|
||||||
invalid_password: "Password must be at least 8 characters.",
|
invalid_password: "Password must be at least 8 characters.",
|
||||||
admin_required: "Only an admin can perform this operation.",
|
admin_required: "Only an admin can perform this operation.",
|
||||||
|
desktop_single_user: "The desktop app is single-user; user management is unavailable.",
|
||||||
not_found: "This resource does not exist, or you do not have access.",
|
not_found: "This resource does not exist, or you do not have access.",
|
||||||
agent_not_found: "This agent no longer exists.",
|
agent_not_found: "This agent no longer exists.",
|
||||||
unknown_agent: "That agent does not exist in this Project.",
|
unknown_agent: "That agent does not exist in this Project.",
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ export const zh = {
|
|||||||
adminOnly: "只有管理员可以在这里执行更新。",
|
adminOnly: "只有管理员可以在这里执行更新。",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Desktop task-completion notifications (window unfocused; desktop-shell sessions only). */
|
||||||
|
notify: {
|
||||||
|
taskCompleteTitle: "任务完成",
|
||||||
|
/** `session` is the Session title (defaultSessionTitle when unnamed). */
|
||||||
|
taskCompleteBody: (session: string): string => `「${session}」已完成,点击查看`,
|
||||||
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
save: "保存",
|
save: "保存",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
@@ -1117,6 +1124,7 @@ Benchmark:
|
|||||||
password_mismatch: "当前密码不正确。",
|
password_mismatch: "当前密码不正确。",
|
||||||
invalid_password: "密码至少 8 位。",
|
invalid_password: "密码至少 8 位。",
|
||||||
admin_required: "仅管理员可执行此操作。",
|
admin_required: "仅管理员可执行此操作。",
|
||||||
|
desktop_single_user: "桌面应用为单用户模式,用户管理不可用。",
|
||||||
not_found: "资源不存在,或你没有访问权限。",
|
not_found: "资源不存在,或你没有访问权限。",
|
||||||
agent_not_found: "该 Agent 已不存在。",
|
agent_not_found: "该 Agent 已不存在。",
|
||||||
unknown_agent: "该 Agent 不存在于本 Project。",
|
unknown_agent: "该 Agent 不存在于本 Project。",
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Desktop task-completion notifications, renderer-side only (standard Web Notification
|
||||||
|
* API — no preload, no private IPC, per design § "桌面端原型 · 进程模型").
|
||||||
|
*
|
||||||
|
* Watches the tracked Session list for active→idle transitions (lib/completion-notify)
|
||||||
|
* and, when the window is hidden or unfocused, shows a system notification with the
|
||||||
|
* Session's title; clicking focuses the window and opens that Session. Gated to
|
||||||
|
* sessionVia === "desktop": only the desktop shell's own window qualifies — there the
|
||||||
|
* Notification permission is granted by default, so plain browsers (which would face a
|
||||||
|
* permission prompt) are never touched.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { S } from "../lib/strings";
|
||||||
|
import { createCompletionTracker } from "../lib/completion-notify";
|
||||||
|
import { useAuth } from "./auth";
|
||||||
|
import { useProject } from "./project";
|
||||||
|
import { useSessions } from "./sessions";
|
||||||
|
|
||||||
|
export function useCompletionNotifications(): void {
|
||||||
|
const { sessionVia } = useAuth();
|
||||||
|
const { sessions } = useSessions();
|
||||||
|
const { setCurrentAgentId } = useProject();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const trackerRef = useRef(createCompletionTracker());
|
||||||
|
// Click handlers fire long after the effect ran: read the latest helpers via refs.
|
||||||
|
const navigateRef = useRef(navigate);
|
||||||
|
navigateRef.current = navigate;
|
||||||
|
const setCurrentAgentIdRef = useRef(setCurrentAgentId);
|
||||||
|
setCurrentAgentIdRef.current = setCurrentAgentId;
|
||||||
|
|
||||||
|
const enabled = sessionVia === "desktop";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
// The tracker must see every snapshot (also while focused): completions the user
|
||||||
|
// watched happen are consumed silently instead of surfacing on a later blur.
|
||||||
|
const completed = trackerRef.current.observe(
|
||||||
|
sessions.map((s) => ({ sessionId: s.sessionId, status: s.status })),
|
||||||
|
);
|
||||||
|
if (completed.length === 0) return;
|
||||||
|
if (!document.hidden && document.hasFocus()) return;
|
||||||
|
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
||||||
|
for (const sessionId of completed) {
|
||||||
|
const session = sessions.find((s) => s.sessionId === sessionId);
|
||||||
|
const title = session?.title ?? S.chat.defaultSessionTitle;
|
||||||
|
const agentId = session?.agentId ?? null;
|
||||||
|
try {
|
||||||
|
const notification = new Notification(S.notify.taskCompleteTitle, {
|
||||||
|
body: S.notify.taskCompleteBody(title),
|
||||||
|
// One notification per Session: a newer completion replaces the stale one.
|
||||||
|
tag: `penguin-task-${sessionId}`,
|
||||||
|
icon: "/penguin-logo.svg",
|
||||||
|
});
|
||||||
|
notification.onclick = () => {
|
||||||
|
notification.close();
|
||||||
|
window.focus();
|
||||||
|
// Mirrors the sidebar's openSession: the current agent follows the Session.
|
||||||
|
if (agentId !== null) setCurrentAgentIdRef.current(agentId);
|
||||||
|
navigateRef.current(`/chat/${sessionId}`);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Notification construction is best-effort; a platform refusing it must not
|
||||||
|
// break the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, sessions]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createCompletionTracker } from "../src/lib/completion-notify";
|
||||||
|
import type { ObservedSession } from "../src/lib/completion-notify";
|
||||||
|
import type { SessionStatus } from "@prismshadow/penguin-server/api";
|
||||||
|
|
||||||
|
const row = (sessionId: string, status: SessionStatus): ObservedSession => ({
|
||||||
|
sessionId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCompletionTracker", () => {
|
||||||
|
it("fires when a known running session goes idle", () => {
|
||||||
|
const t = createCompletionTracker();
|
||||||
|
expect(t.observe([row("a", "running")], 0)).toEqual([]);
|
||||||
|
expect(t.observe([row("a", "idle")], 1000)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats compacting as still active and fires only on the flip to idle", () => {
|
||||||
|
const t = createCompletionTracker();
|
||||||
|
t.observe([row("a", "running")], 0);
|
||||||
|
expect(t.observe([row("a", "compacting")], 100)).toEqual([]);
|
||||||
|
expect(t.observe([row("a", "idle")], 200)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never fires on first observation, even an idle one after a running list load", () => {
|
||||||
|
const t = createCompletionTracker();
|
||||||
|
// A list that loads with idle sessions has witnessed nothing.
|
||||||
|
expect(t.observe([row("a", "idle"), row("b", "idle")], 0)).toEqual([]);
|
||||||
|
expect(t.observe([row("a", "idle"), row("b", "idle")], 100)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes per run: repeated idle observations fire once", () => {
|
||||||
|
const t = createCompletionTracker();
|
||||||
|
t.observe([row("a", "running")], 0);
|
||||||
|
expect(t.observe([row("a", "idle")], 1000)).toEqual(["a"]);
|
||||||
|
expect(t.observe([row("a", "idle")], 2000)).toEqual([]);
|
||||||
|
expect(t.observe([row("a", "idle")], 60_000)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires again for a genuinely new run after the cooldown", () => {
|
||||||
|
const t = createCompletionTracker(10_000);
|
||||||
|
t.observe([row("a", "running")], 0);
|
||||||
|
expect(t.observe([row("a", "idle")], 1000)).toEqual(["a"]);
|
||||||
|
t.observe([row("a", "running")], 20_000);
|
||||||
|
expect(t.observe([row("a", "idle")], 30_000)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses a stale-snapshot flap within the cooldown window", () => {
|
||||||
|
const t = createCompletionTracker(10_000);
|
||||||
|
t.observe([row("a", "running")], 0);
|
||||||
|
expect(t.observe([row("a", "idle")], 1000)).toEqual(["a"]);
|
||||||
|
// A stale reload snapshot resurrects "running", then the fresh state lands again:
|
||||||
|
// the same finish must not notify twice.
|
||||||
|
t.observe([row("a", "running")], 1100);
|
||||||
|
expect(t.observe([row("a", "idle")], 1200)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks sessions independently and reports multiple completions", () => {
|
||||||
|
const t = createCompletionTracker();
|
||||||
|
t.observe([row("a", "running"), row("b", "running"), row("c", "idle")], 0);
|
||||||
|
expect(t.observe([row("a", "idle"), row("b", "idle"), row("c", "idle")], 1000)).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes sessions that leave the list: reappearing is a fresh first observation", () => {
|
||||||
|
const t = createCompletionTracker();
|
||||||
|
t.observe([row("a", "running")], 0);
|
||||||
|
// Project switch empties the list; the session's state is dropped.
|
||||||
|
t.observe([], 100);
|
||||||
|
expect(t.observe([row("a", "idle")], 200)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores duplicate ids within one observation (first occurrence wins)", () => {
|
||||||
|
const t = createCompletionTracker();
|
||||||
|
t.observe([row("a", "running")], 0);
|
||||||
|
expect(t.observe([row("a", "idle"), row("a", "running")], 1000)).toEqual(["a"]);
|
||||||
|
// The duplicate "running" row did not restart the run.
|
||||||
|
expect(t.observe([row("a", "idle")], 60_000)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+3
@@ -114,6 +114,9 @@ importers:
|
|||||||
|
|
||||||
packages/desktop:
|
packages/desktop:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@prismshadow/penguin-cli':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: file:packages/cli(supports-color@10.2.2)
|
||||||
'@prismshadow/penguin-core':
|
'@prismshadow/penguin-core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: file:packages/core(supports-color@10.2.2)(ws@8.21.0)
|
version: file:packages/core(supports-color@10.2.2)(ws@8.21.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user