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:
@@ -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).
|
||||
*
|
||||
* `pnpm deploy --prod` materializes this package plus its production dependency tree —
|
||||
* including the workspace packages — into a portable directory whose symlinks all stay
|
||||
* inside it (verified: the server boots from the deploy dir as-is). On top of that:
|
||||
* including the workspace packages (@prismshadow/penguin-cli among them, so the staged
|
||||
* node_modules carries cli + server + core + skills) — into a portable directory whose
|
||||
* symlinks all stay inside it (verified: the server boots from the deploy dir as-is).
|
||||
* On top of that:
|
||||
* - prune dev files (sources, configs) so only dist/, node_modules/ and package.json ship;
|
||||
* - copy the web build to `node_modules/@prismshadow/penguin-server/web-dist`, the npm
|
||||
* package layout the server's static-hosting lookup checks first;
|
||||
* - copy build/icon.png into the app dir (the runtime window icon, see src/app-icon.ts);
|
||||
* - generate the `penguin` CLI launchers into `bin/` (POSIX + Windows, see
|
||||
* src/launcher.ts): they run the bundled CLI on the app's Electron runtime as Node;
|
||||
* - ensure `stage/minigit` exists (may be empty): the Windows CI job downloads MinGit
|
||||
* into it, and electron-builder's win extraResources entry must always have a source.
|
||||
*
|
||||
* Run from anywhere; all paths are derived from this file's location.
|
||||
* Run from anywhere (after `pnpm -r build`); all paths derive from this file's location.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(pkgDir, "..", "..");
|
||||
@@ -76,6 +81,44 @@ if (!fs.existsSync(path.join(webDist, "index.html"))) {
|
||||
const serverPkg = path.join(appDir, "node_modules", "@prismshadow", "penguin-server");
|
||||
fs.cpSync(webDist, path.join(serverPkg, "web-dist"), { recursive: true, dereference: true });
|
||||
|
||||
// The shell forks the server by this exact path, and the CLI launchers point at the
|
||||
// CLI entry: verify both landed in the deploy tree before packing.
|
||||
const launcherModule = path.join(pkgDir, "dist", "launcher.js");
|
||||
for (const [what, file] of [
|
||||
["server entry", path.join(serverPkg, "dist", "index.js")],
|
||||
[
|
||||
"CLI entry",
|
||||
path.join(appDir, "node_modules", "@prismshadow", "penguin-cli", "dist", "index.js"),
|
||||
],
|
||||
["launcher generator (dist/launcher.js)", launcherModule],
|
||||
]) {
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error(`[stage] missing ${what} (${file}) — run \`pnpm -r build\` first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime window icon: same app-dir-relative location as the dev run (build/icon.png),
|
||||
// so src/app-icon.ts resolves it identically in both layouts.
|
||||
const iconSrc = path.join(pkgDir, "build", "icon.png");
|
||||
if (!fs.existsSync(iconSrc)) {
|
||||
console.error("[stage] build/icon.png is missing — run `node scripts/render-icon.mjs`.");
|
||||
process.exit(1);
|
||||
}
|
||||
fs.mkdirSync(path.join(appDir, "build"), { recursive: true });
|
||||
fs.copyFileSync(iconSrc, path.join(appDir, "build", "icon.png"));
|
||||
|
||||
// CLI launchers (bin/penguin, bin/penguin.cmd): generated, not committed — the script
|
||||
// text lives in src/launcher.ts so it is unit-tested with the rest of the shell.
|
||||
const { posixLauncherScript, windowsLauncherScript } = await import(
|
||||
pathToFileURL(launcherModule).href
|
||||
);
|
||||
const binDir = path.join(appDir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(binDir, "penguin"), posixLauncherScript(), { mode: 0o755 });
|
||||
fs.chmodSync(path.join(binDir, "penguin"), 0o755);
|
||||
fs.writeFileSync(path.join(binDir, "penguin.cmd"), windowsLauncherScript());
|
||||
|
||||
fs.mkdirSync(path.join(stageDir, "minigit"), { recursive: true });
|
||||
|
||||
console.log("[stage] done:", appDir);
|
||||
|
||||
Reference in New Issue
Block a user