@mwguerra/hull 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +631 -0
  3. package/assets/hull-logo.png +0 -0
  4. package/assets/hull-logo.svg +5 -0
  5. package/bin/hull.js +4 -0
  6. package/devtools/dist/index.html +29 -0
  7. package/host/CMakeLists.txt +101 -0
  8. package/host/README.md +94 -0
  9. package/host/linux.Dockerfile +26 -0
  10. package/host/src/bindings/credentials.hpp +35 -0
  11. package/host/src/bindings/database.hpp +51 -0
  12. package/host/src/bindings/files.hpp +58 -0
  13. package/host/src/bindings/http.hpp +84 -0
  14. package/host/src/bindings/printer.hpp +281 -0
  15. package/host/src/bindings/storage.hpp +71 -0
  16. package/host/src/db_core.hpp +198 -0
  17. package/host/src/dispatcher.hpp +81 -0
  18. package/host/src/file_store.hpp +91 -0
  19. package/host/src/keychain.hpp +157 -0
  20. package/host/src/main.cpp +386 -0
  21. package/host/src/paths.hpp +62 -0
  22. package/host/src/secure.hpp +124 -0
  23. package/host/src/serve.hpp +113 -0
  24. package/host/test/db_test.cpp +80 -0
  25. package/host/test/secure_files_test.cpp +68 -0
  26. package/host/third_party/sqlite/sqlite3.c +269376 -0
  27. package/host/third_party/sqlite/sqlite3.h +14347 -0
  28. package/package.json +58 -0
  29. package/src/bridge/bridge-core.js +92 -0
  30. package/src/bridge/index.js +139 -0
  31. package/src/bridge/native-store.js +34 -0
  32. package/src/cli/build.js +122 -0
  33. package/src/cli/config.js +102 -0
  34. package/src/cli/dev.js +158 -0
  35. package/src/cli/eject.js +39 -0
  36. package/src/cli/host.js +61 -0
  37. package/src/cli/index.js +54 -0
  38. package/src/cli/installer.js +265 -0
  39. package/src/cli/release.js +178 -0
  40. package/src/cli/start.js +45 -0
  41. package/src/cli/timing.js +22 -0
  42. package/src/cli/vite.js +16 -0
  43. package/src/react/index.js +30 -0
  44. package/src/vue/index.js +31 -0
package/src/cli/dev.js ADDED
@@ -0,0 +1,158 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadConfig, hostArgs, hostEnv } from "./config.js";
7
+ import { resolveHost } from "./host.js";
8
+ import { loadVite } from "./vite.js";
9
+ import { createTimer } from "./timing.js";
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ const INSPECTOR_HTML = path.resolve(here, "../../devtools/dist/index.html"); // built single file
13
+
14
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
15
+ function freePort() {
16
+ return new Promise((res, rej) => {
17
+ const s = net.createServer();
18
+ s.on("error", rej);
19
+ s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => res(p)); });
20
+ });
21
+ }
22
+ function openUrl(url) {
23
+ const [cmd, args] =
24
+ process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
25
+ : process.platform === "darwin" ? ["open", [url]]
26
+ : ["xdg-open", [url]];
27
+ try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch { /* ignore */ }
28
+ }
29
+ async function waitForHealth(url) {
30
+ for (let i = 0; i < 60; i++) {
31
+ try { const r = await fetch(`${url}/health`); if (r.ok) return true; } catch { /* not up yet */ }
32
+ await sleep(100);
33
+ }
34
+ return false;
35
+ }
36
+
37
+ // Make sure the dev server is actually serving the app before we open the native
38
+ // window. server.listen() resolves when the port is bound, but on a cold start (or
39
+ // after Vite re-optimizes deps) the first load can come back incomplete, and WebKitGTK
40
+ // shows a blank page without retrying -> intermittent white screen in `hull dev`. So we
41
+ // fetch the index, then warm the entry module (runs Vite's transform + dep optimize) so
42
+ // the page the web view loads is already fully ready.
43
+ async function waitForApp(server, url) {
44
+ let html = null;
45
+ for (let i = 0; i < 120 && html === null; i++) { // ~6s
46
+ try { const r = await fetch(url); if (r.ok) html = await r.text(); } catch { /* not up */ }
47
+ if (html === null) await sleep(50);
48
+ }
49
+ if (!html) return;
50
+ const m = html.match(/<script[^>]*type=["']module["'][^>]*src=["']([^"']+)["']/i);
51
+ if (!m) return;
52
+ try { await server.warmupRequest?.(m[1]); } catch { /* best effort */ }
53
+ try { await fetch(new URL(m[1], url).href); } catch { /* best effort */ }
54
+ }
55
+
56
+ // Vite plugin (browser mode only): inject the bridge URL into every served page and
57
+ // serve the inspector at /__hull/devtools. Never part of `hull build`.
58
+ function devBrowserPlugin(bridgeUrl) {
59
+ const inject = (html) =>
60
+ html.replace(/<head>/i, `<head><script>window.__HULL_BRIDGE__=${JSON.stringify(bridgeUrl)};</script>`);
61
+ return {
62
+ name: "hull:dev-browser",
63
+ transformIndexHtml(html) { return inject(html); },
64
+ configureServer(server) {
65
+ server.middlewares.use("/__hull/devtools", (_req, res) => {
66
+ let html = "<!doctype html><p>Inspector not built. Run <code>npm run build:devtools</code>.</p>";
67
+ try { html = inject(fs.readFileSync(INSPECTOR_HTML, "utf8")); } catch { /* fall through */ }
68
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
69
+ res.end(html);
70
+ });
71
+ },
72
+ };
73
+ }
74
+
75
+ export async function dev(cwd, args, { verbose } = {}) {
76
+ const timer = createTimer(verbose);
77
+ const browser = args.includes("--browser");
78
+ const cfg = await loadConfig(cwd);
79
+ timer.step("config loaded");
80
+ const vite = await loadVite(cwd);
81
+ timer.step("vite loaded");
82
+
83
+ // ---- Browser mode: UI in your browser (full HMR), bridge over HTTP/SSE ----
84
+ if (browser) {
85
+ const port = await freePort();
86
+ const bridgeUrl = `http://127.0.0.1:${port}`;
87
+ const server = await vite.createServer({
88
+ root: cwd,
89
+ server: { open: false },
90
+ plugins: [devBrowserPlugin(bridgeUrl)],
91
+ });
92
+ await server.listen();
93
+ const appUrl = server.resolvedUrls?.local?.[0] ?? `http://localhost:5173/`;
94
+ timer.step("vite dev server");
95
+
96
+ const { binary } = await resolveHost({ secure: cfg.secure });
97
+ const host = spawn(binary, ["--serve", String(port), "--inspect", "--app-id", cfg.appId],
98
+ { stdio: "inherit" });
99
+ await waitForHealth(bridgeUrl);
100
+ timer.step("host bridge server");
101
+
102
+ openUrl(appUrl);
103
+ openUrl(`${appUrl}__hull/devtools`);
104
+ console.log(`hull dev --browser${cfg.secure ? " (secure host)" : ""}:`);
105
+ console.log(` app: ${appUrl}`);
106
+ console.log(` inspector: ${appUrl}__hull/devtools`);
107
+ console.log(` bridge: ${bridgeUrl} (edit UI freely — just reload, no recompile)`);
108
+ timer.total("hull dev --browser ready");
109
+
110
+ const shutdown = async () => { try { host.kill(); } catch {} try { await server.close(); } catch {} process.exit(0); };
111
+ host.on("exit", shutdown);
112
+ process.on("SIGINT", shutdown);
113
+ process.on("SIGTERM", shutdown);
114
+ return;
115
+ }
116
+
117
+ // ---- Native window mode: Vite dev server rendered in the host web view ----
118
+ // The app runs in the native window; the companion inspector opens as a browser tab
119
+ // fed by the host's trace server (same inspector UI as browser mode).
120
+ const inspectPort = await freePort();
121
+ const traceUrl = `http://127.0.0.1:${inspectPort}`;
122
+ const server = await vite.createServer({
123
+ root: cwd,
124
+ server: { open: false },
125
+ plugins: [devBrowserPlugin(traceUrl)], // serves the inspector at /__hull/devtools
126
+ });
127
+ await server.listen();
128
+ const url =
129
+ server.resolvedUrls?.local?.[0] ??
130
+ `http://localhost:${server.config.server.port ?? 5173}/`;
131
+ timer.step("vite dev server");
132
+
133
+ console.log(`hull dev: serving ${url}${cfg.secure ? " (secure host)" : ""}`);
134
+
135
+ // Don't open the window until the app is actually served (avoids a cold-start blank).
136
+ await waitForApp(server, url);
137
+ timer.step("dev server ready");
138
+
139
+ const { binary } = await resolveHost({ secure: cfg.secure });
140
+ // --inspect enables the trace; --inspect-port runs the trace server for the inspector.
141
+ const env = { ...process.env, ...hostEnv(cfg, { noSandbox: args.includes("--no-sandbox") }) };
142
+ const child = spawn(
143
+ binary,
144
+ ["--url", url, "--inspect", "--inspect-port", String(inspectPort), ...hostArgs(cfg)],
145
+ { stdio: "inherit", env });
146
+ timer.step("native window launched");
147
+
148
+ if (await waitForHealth(traceUrl)) {
149
+ openUrl(`${url}__hull/devtools`);
150
+ console.log(` inspector: ${url}__hull/devtools`);
151
+ }
152
+ timer.total("hull dev ready");
153
+
154
+ const shutdown = async () => { try { await server.close(); } catch {} process.exit(0); };
155
+ child.on("exit", shutdown);
156
+ process.on("SIGINT", shutdown);
157
+ process.on("SIGTERM", shutdown);
158
+ }
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createTimer } from "./timing.js";
5
+
6
+ // Copy the C++ host project into ./desktop so you can add custom native bindings
7
+ // and compile your own host. The standard bindings (HTTP/storage/keychain/print)
8
+ // are already there to extend. Requires a C++ toolchain (see desktop/README.md).
9
+ export async function eject(cwd, _args, { verbose } = {}) {
10
+ const timer = createTimer(verbose);
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+ const hostSrc = path.resolve(here, "../../host"); // packages/hull/host
13
+ const dest = path.join(cwd, "desktop");
14
+
15
+ if (!fs.existsSync(hostSrc)) {
16
+ throw new Error(`bundled host sources not found at ${hostSrc}`);
17
+ }
18
+ if (fs.existsSync(dest)) {
19
+ throw new Error(`${path.relative(cwd, dest)} already exists — remove it first`);
20
+ }
21
+ timer.step("located host sources");
22
+
23
+ copyDir(hostSrc, dest);
24
+ timer.step("copied host project");
25
+ console.log(`hull eject: C++ host copied to ./desktop`);
26
+ console.log(` add bindings under desktop/src/bindings, then build with CMake (see desktop/README.md).`);
27
+ timer.total("hull eject");
28
+ }
29
+
30
+ function copyDir(src, dest) {
31
+ fs.mkdirSync(dest, { recursive: true });
32
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
33
+ if (entry.name === "build" || entry.name === "node_modules") continue; // skip artifacts
34
+ const s = path.join(src, entry.name);
35
+ const d = path.join(dest, entry.name);
36
+ if (entry.isDirectory()) copyDir(s, d);
37
+ else fs.copyFileSync(s, d);
38
+ }
39
+ }
@@ -0,0 +1,61 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ // Known target keys (os-arch), matching the platform package names.
6
+ export const KNOWN_TARGETS = [
7
+ "win32-x64",
8
+ "darwin-arm64",
9
+ "linux-x64",
10
+ ];
11
+
12
+ export const currentTarget = () => `${process.platform}-${process.arch}`;
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ // Load a platform package's entry. We try a normal import first (works when the
17
+ // package is installed for end users via the published optionalDependencies), then
18
+ // fall back to the sibling path. The sibling path is identical in both layouts:
19
+ // monorepo: packages/hull/src/cli -> ../../../hull-<key>
20
+ // installed: @mwguerra/hull/src/cli -> ../../../hull-<key> (same @mwguerra scope)
21
+ async function loadPlatform(key) {
22
+ try {
23
+ return await import(`@mwguerra/hull-${key}`);
24
+ } catch {
25
+ const rel = path.resolve(here, `../../../hull-${key}/index.js`);
26
+ if (fs.existsSync(rel)) return import(pathToFileURL(rel).href);
27
+ return null;
28
+ }
29
+ }
30
+
31
+ // The on-disk binary name for a platform + flavor.
32
+ export function binaryName(key, secure) {
33
+ const base = secure ? "hull-host-secure" : "hull-host";
34
+ return key.startsWith("win32-") ? `${base}.exe` : base;
35
+ }
36
+
37
+ // Resolve a specific platform's prebuilt host. Returns null if neither the default
38
+ // nor secure binary has been built yet (so callers can skip the platform).
39
+ export async function resolveHostFor(key) {
40
+ const mod = await loadPlatform(key);
41
+ if (!mod || !mod.hostDir) return null;
42
+ const hostBinary = mod.hostBinary && fs.existsSync(mod.hostBinary) ? mod.hostBinary : null;
43
+ const secureBinary = mod.secureBinary && fs.existsSync(mod.secureBinary) ? mod.secureBinary : null;
44
+ if (!hostBinary && !secureBinary) return null;
45
+ return { key, pkg: `@mwguerra/hull-${key}`, hostDir: mod.hostDir, hostBinary, secureBinary };
46
+ }
47
+
48
+ // Resolve the runnable binary for the CURRENT platform and the requested flavor,
49
+ // throwing a helpful error if it isn't built.
50
+ export async function resolveHost({ secure = false } = {}) {
51
+ const key = currentTarget();
52
+ const found = await resolveHostFor(key);
53
+ const binary = found && (secure ? found.secureBinary : found.hostBinary);
54
+ if (binary) return { ...found, binary, secure };
55
+ const flavor = secure ? "secure host (-DHULL_CRYPTO=ON)" : "host";
56
+ throw new Error(
57
+ `no prebuilt Hull ${flavor} for "${key}".\n` +
58
+ ` Build it with "npm run build:host${secure ? ":secure" : ""}", install the published\n` +
59
+ ` @mwguerra/hull-${key}, or run "hull eject" to build from source.`
60
+ );
61
+ }
@@ -0,0 +1,54 @@
1
+ import { dev } from "./dev.js";
2
+ import { build } from "./build.js";
3
+ import { start } from "./start.js";
4
+ import { eject } from "./eject.js";
5
+ import { installer } from "./installer.js";
6
+
7
+ const HELP = `
8
+ hull — tiny native desktop apps from your web UI
9
+
10
+ Usage: hull <command> [options]
11
+
12
+ dev Start the Vite dev server and open it in a native window (HMR)
13
+ build Build the single-file UI and package it with the native host
14
+ start Run the packaged app from ./release
15
+ installer Wrap the build into a native installer (.dmg / .deb / .exe)
16
+ eject Copy the C++ host project into ./desktop for custom native code
17
+ help Show this help
18
+
19
+ Options:
20
+ -v, --verbose Print per-step timings (every command also prints its total time)
21
+
22
+ Config is optional. Defaults come from package.json; override in .hullrc:
23
+ { "appId": "com.you.app", "secure": false, "window": { "title": "App" } }
24
+ `;
25
+
26
+ export async function run(argv) {
27
+ const verbose = argv.includes("-v") || argv.includes("--verbose");
28
+ const rest = argv.filter((a) => a !== "-v" && a !== "--verbose");
29
+ const [cmd, ...args] = rest;
30
+ const cwd = process.cwd();
31
+ const opts = { verbose };
32
+ try {
33
+ switch (cmd) {
34
+ case "dev": await dev(cwd, args, opts); break;
35
+ case "build": await build(cwd, args, opts); break;
36
+ case "start": await start(cwd, args, opts); break;
37
+ case "installer": await installer(cwd, args, opts); break;
38
+ case "eject": await eject(cwd, args, opts); break;
39
+ case undefined:
40
+ case "help":
41
+ case "-h":
42
+ case "--help":
43
+ console.log(HELP);
44
+ break;
45
+ default:
46
+ console.error(`hull: unknown command "${cmd}"`);
47
+ console.log(HELP);
48
+ process.exit(1);
49
+ }
50
+ } catch (e) {
51
+ console.error(`hull ${cmd ?? ""}: ${e.message}`);
52
+ process.exit(1);
53
+ }
54
+ }
@@ -0,0 +1,265 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { execFileSync } from "node:child_process";
5
+ import { loadConfig } from "./config.js";
6
+ import { currentTarget, binaryName } from "./host.js";
7
+ import { parseVersion, sanitize } from "./release.js";
8
+ import { createTimer } from "./timing.js";
9
+
10
+ // hull installer [vX.Y.Z]
11
+ // Wrap the already-built bundle for the CURRENT platform into a native installer:
12
+ // macOS -> .dmg (hdiutil; the .app + an Applications drop-link)
13
+ // Linux -> .deb (dpkg-deb; installs to /opt + a .desktop + icon, declares deps)
14
+ // Windows -> .exe (Inno Setup; per-user install, Start Menu/Desktop shortcuts)
15
+ // Each runs on its own OS (the packaging tools are OS-native). Run `hull build` first.
16
+ export async function installer(cwd, args, { verbose } = {}) {
17
+ const timer = createTimer(verbose);
18
+ const version = args.find((a) => !a.startsWith("-")) ?? null;
19
+ const { label } = parseVersion(version);
20
+ const cfg = await loadConfig(cwd);
21
+ const key = currentTarget();
22
+ const outDir = path.join(cwd, cfg.releaseDir, label);
23
+ const bundleDir = path.join(outDir, key + (cfg.secure ? "-secure" : ""));
24
+ if (!fs.existsSync(bundleDir)) {
25
+ const v = label === "development" ? "" : " " + label;
26
+ throw new Error(`no build at ${path.relative(cwd, bundleDir)}. Run "hull build${v}" first.`);
27
+ }
28
+ const base = `${sanitize(cfg.title)}-${label}-${key}${cfg.secure ? "-secure" : ""}`;
29
+ const ver = label === "development" ? "0.0.0" : label.replace(/^v/, ""); // installer version
30
+ const binName = binaryName(key, cfg.secure); // hull-host[-secure][.exe]
31
+ timer.step("located build");
32
+
33
+ let out;
34
+ if (process.platform === "darwin") out = macDmg(bundleDir, outDir, base, cfg);
35
+ else if (process.platform === "win32") out = winInno(bundleDir, outDir, base, cfg, ver, binName);
36
+ else out = linuxDeb(bundleDir, outDir, base, cfg, key, ver, binName);
37
+
38
+ const rel = path.relative(cwd, out).replace(/\\/g, "/");
39
+ console.log(`\nhull installer: ${rel} (${(fs.statSync(out).size / 1048576).toFixed(1)} MB)`);
40
+ timer.total("hull installer");
41
+ }
42
+
43
+ // ---------------------------------- macOS (.dmg) ----------------------------------
44
+ function macDmg(bundleDir, outDir, base, cfg) {
45
+ const appName = `${sanitize(cfg.title)}.app`;
46
+ const appPath = path.join(bundleDir, appName);
47
+ if (!fs.existsSync(appPath)) {
48
+ throw new Error(`no ${appName} in the build — run "hull build" on macOS first.`);
49
+ }
50
+ const stage = path.join(outDir, ".dmg-stage");
51
+ fs.rmSync(stage, { recursive: true, force: true });
52
+ fs.mkdirSync(stage, { recursive: true });
53
+ execFileSync("cp", ["-R", appPath, path.join(stage, appName)]);
54
+ try { fs.symlinkSync("/Applications", path.join(stage, "Applications")); } catch { /* exists */ }
55
+
56
+ const dmg = path.join(outDir, `${base}.dmg`);
57
+ fs.rmSync(dmg, { force: true });
58
+ execFileSync("hdiutil", ["create", "-volname", cfg.title, "-srcfolder", stage,
59
+ "-ov", "-format", "UDZO", dmg], { stdio: "inherit" });
60
+ fs.rmSync(stage, { recursive: true, force: true });
61
+ return dmg;
62
+ }
63
+
64
+ // ---------------------------------- Linux (.deb) ----------------------------------
65
+ function debArch(key) {
66
+ return key.endsWith("-arm64") ? "arm64" : "amd64";
67
+ }
68
+ function debName(cfg) {
69
+ // Debian package names: lowercase letters, digits, '+', '-', '.'; start alphanumeric.
70
+ const base = (cfg.appId?.split(".").pop() || sanitize(cfg.title)).toLowerCase();
71
+ return base.replace(/[^a-z0-9+.-]+/g, "-").replace(/^[^a-z0-9]+/, "") || "hull-app";
72
+ }
73
+ // Resolve shared-library Depends from the binary using dpkg-shlibdeps (part of
74
+ // dpkg-dev, present on any host that built the C++ host). Version-proof: it emits the
75
+ // exact runtime package names for the running distro. Falls back if unavailable.
76
+ function computeDebDeps(stage, pkg, binName, cfg) {
77
+ try {
78
+ const debianDir = path.join(stage, "debian");
79
+ fs.mkdirSync(debianDir, { recursive: true });
80
+ fs.writeFileSync(path.join(debianDir, "control"),
81
+ `Source: ${pkg}\nMaintainer: ${cfg.appId}\n\nPackage: ${pkg}\nArchitecture: any\n`);
82
+ const out = execFileSync("dpkg-shlibdeps", ["-O", path.join("opt", pkg, binName)],
83
+ { cwd: stage, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
84
+ fs.rmSync(debianDir, { recursive: true, force: true });
85
+ const m = /shlibs:Depends=(.+)/.exec(out);
86
+ if (m && m[1].trim()) return m[1].trim();
87
+ } catch { /* dpkg-shlibdeps not available — use the fallback below */ }
88
+ const base = "libwebkitgtk-6.0-4, libgtk-4-1, libsecret-1-0, libcups2";
89
+ return cfg.secure ? `${base}, libsqlcipher0` : base;
90
+ }
91
+
92
+ const xmlEsc = (s) =>
93
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
94
+
95
+ // Write /usr/share/metainfo/<appId>.metainfo.xml (AppStream) so App Center shows the
96
+ // name, summary, license, developer, icon, and version/date instead of "Unknown".
97
+ function writeMetainfo(stage, cfg, ver) {
98
+ const dir = path.join(stage, "usr", "share", "metainfo");
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ const summary = (cfg.description || cfg.title).split("\n")[0].slice(0, 80);
101
+ const devId = cfg.appId.split(".").slice(0, 2).join(".") || cfg.appId; // e.g. com.you
102
+ const devName = cfg.publisher || devId;
103
+ const date = new Date().toISOString().slice(0, 10);
104
+ const xml =
105
+ `<?xml version="1.0" encoding="UTF-8"?>
106
+ <component type="desktop-application">
107
+ <id>${cfg.appId}</id>
108
+ <name>${xmlEsc(cfg.title)}</name>
109
+ <summary>${xmlEsc(summary)}</summary>
110
+ <metadata_license>CC0-1.0</metadata_license>
111
+ ${cfg.license ? ` <project_license>${xmlEsc(cfg.license)}</project_license>\n` : ""} <description><p>${xmlEsc(cfg.description || cfg.title)}</p></description>
112
+ <launchable type="desktop-id">${cfg.appId}.desktop</launchable>
113
+ <icon type="stock">${cfg.appId}</icon>
114
+ <developer id="${xmlEsc(devId)}"><name>${xmlEsc(devName)}</name></developer>
115
+ <developer_name>${xmlEsc(devName)}</developer_name>
116
+ <content_rating type="oars-1.1"/>
117
+ <releases>
118
+ <release version="${ver}" date="${date}"/>
119
+ </releases>
120
+ </component>
121
+ `;
122
+ fs.writeFileSync(path.join(dir, `${cfg.appId}.metainfo.xml`), xml);
123
+ }
124
+
125
+ function linuxDeb(bundleDir, outDir, base, cfg, key, ver, binName) {
126
+ const pkg = debName(cfg);
127
+ const stage = path.join(outDir, ".deb-stage");
128
+ fs.rmSync(stage, { recursive: true, force: true });
129
+
130
+ // Payload -> /opt/<pkg>
131
+ const opt = path.join(stage, "opt", pkg);
132
+ fs.mkdirSync(opt, { recursive: true });
133
+ for (const e of fs.readdirSync(bundleDir)) {
134
+ fs.cpSync(path.join(bundleDir, e), path.join(opt, e), { recursive: true });
135
+ }
136
+ fs.chmodSync(path.join(opt, binName), 0o755);
137
+
138
+ // Launcher -> /usr/bin/<pkg>
139
+ const usrbin = path.join(stage, "usr", "bin");
140
+ fs.mkdirSync(usrbin, { recursive: true });
141
+ fs.writeFileSync(path.join(usrbin, pkg),
142
+ `#!/bin/sh\n` +
143
+ `exec /opt/${pkg}/${binName} --app /opt/${pkg}/app.html --title "${cfg.title}" ` +
144
+ `--app-id "${cfg.appId}" --width ${cfg.width} --height ${cfg.height} ` +
145
+ `--icon /opt/${pkg}/icon.png "$@"\n`, { mode: 0o755 });
146
+
147
+ // Desktop entry + icon -> the compositor matches the window (app-id) to this .desktop
148
+ const apps = path.join(stage, "usr", "share", "applications");
149
+ fs.mkdirSync(apps, { recursive: true });
150
+ const hasIcon = fs.existsSync(path.join(bundleDir, "icon.png"));
151
+ fs.writeFileSync(path.join(apps, `${cfg.appId}.desktop`),
152
+ `[Desktop Entry]\nType=Application\nName=${cfg.title}\nExec=/usr/bin/${pkg}\n` +
153
+ (hasIcon ? `Icon=${cfg.appId}\n` : "") +
154
+ `StartupWMClass=${cfg.appId}\nTerminal=false\nCategories=Utility;\n`);
155
+ if (hasIcon) {
156
+ const icons = path.join(stage, "usr", "share", "icons", "hicolor", "256x256", "apps");
157
+ fs.mkdirSync(icons, { recursive: true });
158
+ fs.copyFileSync(path.join(bundleDir, "icon.png"), path.join(icons, `${cfg.appId}.png`));
159
+ }
160
+
161
+ // AppStream MetaInfo -> rich metadata in GNOME Software / App Center (name, summary,
162
+ // license, developer, version/date, icon). Without it those fields show "Unknown".
163
+ writeMetainfo(stage, cfg, ver);
164
+
165
+ // Control metadata. Compute Depends from the actual binary via dpkg-shlibdeps so the
166
+ // package names are correct for THIS distro (Ubuntu 24.04 renamed many libs in the
167
+ // t64 transition); fall back to a best-effort list if dpkg-dev isn't installed.
168
+ const depends = computeDebDeps(stage, pkg, binName, cfg);
169
+ const desc = cfg.description || "A desktop app packaged with Hull.";
170
+ const deb = path.join(stage, "DEBIAN");
171
+ fs.mkdirSync(deb, { recursive: true });
172
+ fs.writeFileSync(path.join(deb, "control"),
173
+ `Package: ${pkg}\nVersion: ${ver}\nArchitecture: ${debArch(key)}\n` +
174
+ `Maintainer: ${cfg.publisher || cfg.appId}\nDepends: ${depends}\nSection: utils\n` +
175
+ `Priority: optional\nDescription: ${cfg.title}\n ${desc}\n`);
176
+
177
+ const outFile = path.join(outDir, `${base}.deb`);
178
+ fs.rmSync(outFile, { force: true });
179
+ execFileSync("dpkg-deb", ["--build", "--root-owner-group", stage, outFile], { stdio: "inherit" });
180
+ fs.rmSync(stage, { recursive: true, force: true });
181
+ return outFile;
182
+ }
183
+
184
+ // ---------------------------------- Windows (.exe) ----------------------------------
185
+ function findISCC() {
186
+ const local = process.env.LOCALAPPDATA || path.join(process.env.USERPROFILE || "", "AppData", "Local");
187
+ for (const p of [
188
+ "C:/Program Files (x86)/Inno Setup 6/ISCC.exe",
189
+ "C:/Program Files/Inno Setup 6/ISCC.exe",
190
+ path.join(local, "Programs", "Inno Setup 6", "ISCC.exe"), // winget per-user install
191
+ ]) if (fs.existsSync(p)) return p;
192
+ return null;
193
+ }
194
+
195
+ // Wrap a PNG into a (Vista+) PNG-compressed .ico so Explorer/shortcuts show the logo.
196
+ function pngToIco(png) {
197
+ const w = png.readUInt32BE(16), h = png.readUInt32BE(20); // PNG IHDR width/height
198
+ const head = Buffer.alloc(22);
199
+ head.writeUInt16LE(0, 0); head.writeUInt16LE(1, 2); head.writeUInt16LE(1, 4); // dir: type=icon, count=1
200
+ head.writeUInt8(w >= 256 ? 0 : w, 6); head.writeUInt8(h >= 256 ? 0 : h, 7); // 0 means 256
201
+ head.writeUInt8(0, 8); head.writeUInt8(0, 9);
202
+ head.writeUInt16LE(1, 10); head.writeUInt16LE(32, 12); // planes=1, bpp=32
203
+ head.writeUInt32LE(png.length, 14); head.writeUInt32LE(22, 18); // size, offset
204
+ return Buffer.concat([head, png]);
205
+ }
206
+
207
+ function winInno(bundleDir, outDir, base, cfg, ver, binName) {
208
+ const iscc = findISCC();
209
+ if (!iscc) {
210
+ throw new Error(
211
+ "Inno Setup not found. Install it once, then re-run:\n" +
212
+ " winget install JRSoftware.InnoSetup");
213
+ }
214
+ // App icon for shortcuts (from the bundled icon.png).
215
+ const png = path.join(bundleDir, "icon.png");
216
+ let icoLine = "";
217
+ if (fs.existsSync(png)) {
218
+ fs.writeFileSync(path.join(bundleDir, "icon.ico"), pngToIco(fs.readFileSync(png)));
219
+ icoLine = `IconFilename: "{app}\\icon.ico"`;
220
+ }
221
+ const guid = crypto.createHash("md5").update(cfg.appId).digest("hex").toUpperCase();
222
+ // GUID without braces; the .iss writes `{{<guid>}` so Inno stores it as `{<guid>}`.
223
+ const g = `${guid.slice(0, 8)}-${guid.slice(8, 12)}-${guid.slice(12, 16)}-${guid.slice(16, 20)}-${guid.slice(20)}`;
224
+ // Inno treats `{` as a constant delimiter (escape as `{{`); inside quoted values `"`
225
+ // is doubled. Keeps a title/appId containing { or " from breaking the generated .iss.
226
+ const issStr = (s) => String(s).replace(/\{/g, "{{");
227
+ const t = issStr(cfg.title); // for unquoted directives
228
+ const tq = t.replace(/"/g, '""'); // for quoted contexts
229
+ const appIdq = issStr(cfg.appId).replace(/"/g, '""');
230
+ const params =
231
+ `--app ""{app}\\app.html"" --title ""${tq}"" --app-id ""${appIdq}"" ` +
232
+ `--width ${cfg.width} --height ${cfg.height} --icon ""{app}\\icon.png""`;
233
+
234
+ const iss =
235
+ `[Setup]
236
+ AppId={{${g}}
237
+ AppName=${t}
238
+ AppVersion=${ver}
239
+ DefaultDirName={autopf}\\${sanitize(cfg.title)}
240
+ DefaultGroupName=${t}
241
+ UninstallDisplayIcon={app}\\${binName}
242
+ OutputDir=${outDir}
243
+ OutputBaseFilename=${base}
244
+ Compression=lzma2
245
+ SolidCompression=yes
246
+ PrivilegesRequired=lowest
247
+ ArchitecturesInstallIn64BitMode=x64compatible
248
+
249
+ [Files]
250
+ Source: "${bundleDir}\\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
251
+
252
+ [Icons]
253
+ Name: "{group}\\${tq}"; Filename: "{app}\\${binName}"; Parameters: "${params}"; ${icoLine}
254
+ Name: "{autodesktop}\\${tq}"; Filename: "{app}\\${binName}"; Parameters: "${params}"; ${icoLine}
255
+ Name: "{group}\\Uninstall ${tq}"; Filename: "{uninstallexe}"
256
+
257
+ [Run]
258
+ Filename: "{app}\\${binName}"; Parameters: "${params}"; Description: "Launch ${tq}"; Flags: nowait postinstall skipifsilent
259
+ `;
260
+ const issPath = path.join(outDir, `${base}.iss`);
261
+ fs.writeFileSync(issPath, iss);
262
+ execFileSync(iscc, [issPath], { stdio: "inherit" });
263
+ fs.rmSync(issPath, { force: true });
264
+ return path.join(outDir, `${base}.exe`);
265
+ }