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,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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user