@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
@@ -0,0 +1,178 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import archiver from "archiver";
5
+
6
+ const xmlEscape = (s) =>
7
+ String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
8
+
9
+ const VERSION_RE = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
10
+
11
+ // Files we never ship: webview is header-only (the host doesn't import webview.dll),
12
+ // plus import libs / debug symbols.
13
+ const DENY_NAMES = new Set(["webview.dll"]);
14
+ const DENY_EXT = new Set([".lib", ".exp", ".pdb", ".ilk"]);
15
+
16
+ export function sanitize(name) {
17
+ return name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
18
+ }
19
+
20
+ // Validate and normalize the version arg. Empty -> { label: "development" }.
21
+ export function parseVersion(version) {
22
+ if (!version) return { version: null, label: "development" };
23
+ if (!VERSION_RE.test(version)) {
24
+ throw new Error(`invalid version "${version}" — expected vX.Y.Z (e.g. v1.2.3)`);
25
+ }
26
+ return { version, label: version };
27
+ }
28
+
29
+ const isWin = (key) => key.startsWith("win32-");
30
+
31
+ // Copy the minimal runtime files into the bundle: the chosen host binary (`binName`)
32
+ // plus its runtime libs (DLLs). The other flavor's binary, webview.dll, import libs
33
+ // and debug symbols are skipped.
34
+ export function copyHostFiles(hostDir, destDir, binName) {
35
+ fs.mkdirSync(destDir, { recursive: true });
36
+ for (const entry of fs.readdirSync(hostDir)) {
37
+ if (DENY_NAMES.has(entry) || DENY_EXT.has(path.extname(entry))) continue;
38
+ if (entry.startsWith("hull-host") && entry !== binName) continue; // skip other flavor
39
+ fs.copyFileSync(path.join(hostDir, entry), path.join(destDir, entry));
40
+ }
41
+ }
42
+
43
+ // Write a double-clickable launcher appropriate to the TARGET os, invoking `binName`.
44
+ export function writeLauncher(destDir, key, cfg, binName, iconName) {
45
+ const os = key.split("-")[0];
46
+ if (os === "win32") {
47
+ const name = `${sanitize(cfg.title)}.cmd`;
48
+ const icon = iconName ? ` --icon "%~dp0${iconName}"` : "";
49
+ const body =
50
+ `@echo off\r\n` +
51
+ `"%~dp0${binName}" --app "%~dp0app.html" --title "${cfg.title}" ` +
52
+ `--app-id "${cfg.appId}" --width ${cfg.width} --height ${cfg.height}${icon}\r\n`;
53
+ fs.writeFileSync(path.join(destDir, name), body);
54
+ return { name, exec: false };
55
+ }
56
+ // macOS uses .command (double-clickable in Finder); Linux uses .sh.
57
+ const ext = os === "darwin" ? "command" : "sh";
58
+ const name = `${sanitize(cfg.title)}.${ext}`;
59
+ const icon = iconName ? ` --icon "$DIR/${iconName}"` : "";
60
+ // Linux WebKitGTK sandbox control (no-op on macOS). When unset, the host auto-detects.
61
+ let sandbox = "";
62
+ if (os === "linux") {
63
+ if (cfg.linuxSandbox === false) sandbox = `export WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1\n`;
64
+ else if (cfg.linuxSandbox === true) sandbox = `export HULL_FORCE_SANDBOX=1\n`;
65
+ }
66
+ const body =
67
+ `#!/bin/sh\n` +
68
+ `DIR="$(cd "$(dirname "$0")" && pwd)"\n` +
69
+ sandbox +
70
+ `"$DIR/${binName}" --app "$DIR/app.html" --title "${cfg.title}" ` +
71
+ `--app-id "${cfg.appId}" --width ${cfg.width} --height ${cfg.height}${icon}\n`;
72
+ fs.writeFileSync(path.join(destDir, name), body, { mode: 0o755 });
73
+ return { name, exec: true };
74
+ }
75
+
76
+ // Build a macOS .app bundle inside `bundleDir`. The generic prebuilt host stays the
77
+ // executable; a tiny CFBundleExecutable launcher script execs it with the app's args,
78
+ // and the icon comes from the bundle (CFBundleIconFile) — macOS draws the Dock/Finder
79
+ // icon from the bundle, not at runtime. Returns the .app folder name.
80
+ //
81
+ // Note: the host links Homebrew OpenSSL dylibs by absolute path, so the .app runs on
82
+ // machines that have those (the build machine). Bundling+relinking the dylibs and code
83
+ // signing/notarization for distribution to other Macs is a separate, later step.
84
+ export function writeMacApp(bundleDir, cfg, hostDir, binName, builtHtml, iconPath) {
85
+ const appName = `${sanitize(cfg.title)}.app`;
86
+ const contents = path.join(bundleDir, appName, "Contents");
87
+ const macosDir = path.join(contents, "MacOS");
88
+ const resDir = path.join(contents, "Resources");
89
+ fs.mkdirSync(macosDir, { recursive: true });
90
+ fs.mkdirSync(resDir, { recursive: true });
91
+
92
+ // host binary + runtime libs -> Contents/MacOS ; UI -> Contents/Resources
93
+ copyHostFiles(hostDir, macosDir, binName);
94
+ try { fs.chmodSync(path.join(macosDir, binName), 0o755); } catch { /* best effort */ }
95
+ fs.copyFileSync(builtHtml, path.join(resDir, "app.html"));
96
+
97
+ // Icon: copy the PNG and try to make an .icns (sips, macOS only). CFBundleIconFile is
98
+ // set only if the .icns was produced (a PNG alone won't render as the app icon).
99
+ let iconKey = "";
100
+ if (iconPath && fs.existsSync(iconPath)) {
101
+ fs.copyFileSync(iconPath, path.join(resDir, "icon.png"));
102
+ try {
103
+ execFileSync("sips", ["-s", "format", "icns", iconPath, "--out",
104
+ path.join(resDir, "icon.icns")], { stdio: "ignore" });
105
+ iconKey = " <key>CFBundleIconFile</key><string>icon</string>\n";
106
+ } catch { /* sips unavailable (non-mac build host) -> ship without a bundle icon */ }
107
+ }
108
+
109
+ // CFBundleExecutable: a launcher that execs the host with this app's args.
110
+ const execName = sanitize(cfg.title);
111
+ const script =
112
+ `#!/bin/sh\n` +
113
+ `DIR="$(cd "$(dirname "$0")" && pwd)"\n` +
114
+ `RES="$DIR/../Resources"\n` +
115
+ `exec "$DIR/${binName}" --app "$RES/app.html" --title "${cfg.title}" ` +
116
+ `--app-id "${cfg.appId}" --width ${cfg.width} --height ${cfg.height}\n`;
117
+ fs.writeFileSync(path.join(macosDir, execName), script, { mode: 0o755 });
118
+ try { fs.chmodSync(path.join(macosDir, execName), 0o755); } catch { /* best effort */ }
119
+
120
+ const plist =
121
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
122
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n` +
123
+ `<plist version="1.0">\n<dict>\n` +
124
+ ` <key>CFBundleName</key><string>${xmlEscape(cfg.title)}</string>\n` +
125
+ ` <key>CFBundleDisplayName</key><string>${xmlEscape(cfg.title)}</string>\n` +
126
+ ` <key>CFBundleIdentifier</key><string>${xmlEscape(cfg.appId)}</string>\n` +
127
+ ` <key>CFBundleExecutable</key><string>${xmlEscape(execName)}</string>\n` +
128
+ ` <key>CFBundlePackageType</key><string>APPL</string>\n` +
129
+ ` <key>CFBundleVersion</key><string>1.0</string>\n` +
130
+ ` <key>CFBundleShortVersionString</key><string>1.0</string>\n` +
131
+ ` <key>NSHighResolutionCapable</key><true/>\n` +
132
+ iconKey +
133
+ `</dict>\n</plist>\n`;
134
+ fs.writeFileSync(path.join(contents, "Info.plist"), plist);
135
+ return { appName };
136
+ }
137
+
138
+ // tar.gz a directory tree (the .app), preserving on-disk file modes (exec bits).
139
+ export function makeAppArchive(srcDir, outFile, rootName) {
140
+ return new Promise((resolve, reject) => {
141
+ const output = fs.createWriteStream(outFile);
142
+ const archive = archiver("tar", { gzip: true });
143
+ output.on("close", () => resolve(archive.pointer()));
144
+ archive.on("error", reject);
145
+ archive.pipe(output);
146
+ archive.directory(srcDir, rootName);
147
+ archive.finalize();
148
+ });
149
+ }
150
+
151
+ // Default archive format per target: zip for Windows, tar.gz for unix.
152
+ export function defaultFormat(key) {
153
+ return isWin(key) ? "zip" : "tar.gz";
154
+ }
155
+
156
+ // Create an archive whose single top-level folder is `rootName`. On unix targets,
157
+ // the binary + shell launcher are marked executable (0755) regardless of build host.
158
+ export function makeArchive(srcDir, outFile, format, rootName, key, launcherName, binName) {
159
+ return new Promise((resolve, reject) => {
160
+ const output = fs.createWriteStream(outFile);
161
+ const archive =
162
+ format === "zip"
163
+ ? archiver("zip", { zlib: { level: 9 } })
164
+ : archiver("tar", { gzip: true });
165
+ const execNames = isWin(key) ? [] : [binName, launcherName];
166
+
167
+ output.on("close", () => resolve(archive.pointer()));
168
+ archive.on("error", reject);
169
+ archive.pipe(output);
170
+
171
+ for (const entry of fs.readdirSync(srcDir)) {
172
+ const opts = { name: `${rootName}/${entry}` };
173
+ if (execNames.includes(entry)) opts.mode = 0o755;
174
+ archive.file(path.join(srcDir, entry), opts);
175
+ }
176
+ archive.finalize();
177
+ });
178
+ }
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { loadConfig, hostArgs, hostEnv } from "./config.js";
5
+ import { resolveHost, currentTarget } from "./host.js";
6
+ import { parseVersion, sanitize } from "./release.js";
7
+ import { createTimer } from "./timing.js";
8
+
9
+ // Run a packaged app: hull start [vX.Y.Z] (defaults to the development build)
10
+ export async function start(cwd, args, { verbose } = {}) {
11
+ const timer = createTimer(verbose);
12
+ const version = args.find((a) => !a.startsWith("-")) ?? null;
13
+ const { label } = parseVersion(version);
14
+ const cfg = await loadConfig(cwd);
15
+ timer.step("config loaded");
16
+
17
+ // The packaged bundle dir carries a "-secure" suffix when secure.
18
+ const bundleDir = currentTarget() + (cfg.secure ? "-secure" : "");
19
+ const dir = path.join(cwd, cfg.releaseDir, label, bundleDir);
20
+ const missing = () => {
21
+ const v = label === "development" ? "" : " " + label;
22
+ return new Error(`no build at ${path.relative(cwd, dir)}. Run "hull build${v}" first.`);
23
+ };
24
+
25
+ // macOS: the build produced a .app — launch it via `open` so LaunchServices shows the
26
+ // bundle icon (Dock/Finder). `-W` waits until the app quits.
27
+ if (process.platform === "darwin") {
28
+ const appPath = path.join(dir, `${sanitize(cfg.title)}.app`);
29
+ if (!fs.existsSync(appPath)) throw missing();
30
+ timer.total("hull start");
31
+ const child = spawn("open", ["-W", appPath], { stdio: "inherit" });
32
+ child.on("exit", (code) => process.exit(code ?? 0));
33
+ return;
34
+ }
35
+
36
+ const appHtml = path.join(dir, "app.html");
37
+ if (!fs.existsSync(appHtml)) throw missing();
38
+
39
+ const { binary } = await resolveHost({ secure: cfg.secure });
40
+ timer.step("host resolved");
41
+ timer.total("hull start");
42
+ const env = { ...process.env, ...hostEnv(cfg, { noSandbox: args.includes("--no-sandbox") }) };
43
+ const child = spawn(binary, ["--app", appHtml, ...hostArgs(cfg)], { stdio: "inherit", env });
44
+ child.on("exit", (code) => process.exit(code ?? 0));
45
+ }
@@ -0,0 +1,22 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ // Per-command timing. Total is always printed; per-step lines only with -v/--verbose,
4
+ // so you can feel where the time goes (e.g. the Vite build vs. archiving).
5
+ export function createTimer(verbose) {
6
+ const start = performance.now();
7
+ let prev = start;
8
+ const fmt = (ms) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`);
9
+ return {
10
+ verbose,
11
+ step(label) {
12
+ const now = performance.now();
13
+ if (verbose) {
14
+ console.log(` · ${label.padEnd(30)} ${fmt(now - prev).padStart(8)} (elapsed ${fmt(now - start)})`);
15
+ }
16
+ prev = now;
17
+ },
18
+ total(label) {
19
+ console.log(`${label} — ${fmt(performance.now() - start)} total`);
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,16 @@
1
+ import { createRequire } from "node:module";
2
+ import { pathToFileURL } from "node:url";
3
+ import path from "node:path";
4
+
5
+ // Load the *project's own* Vite (resolved from the app's node_modules) so the
6
+ // version always matches the user's framework plugins. Vite is a peer dependency.
7
+ export async function loadVite(cwd) {
8
+ const require = createRequire(pathToFileURL(path.join(cwd, "package.json")).href);
9
+ let vitePath;
10
+ try {
11
+ vitePath = require.resolve("vite");
12
+ } catch {
13
+ throw new Error('Vite was not found in this project. Install it with "npm i -D vite".');
14
+ }
15
+ return import(pathToFileURL(vitePath).href);
16
+ }
@@ -0,0 +1,30 @@
1
+ // React adapter: import { useNativeState } from "@mwguerra/hull/react";
2
+ import { useEffect, useRef, useState, useCallback } from "react";
3
+ import { nativeSetting } from "../bridge/native-store.js";
4
+
5
+ // Like useState, but two-way-bound to a C++-persisted setting.
6
+ // Returns [value, setValue]; setValue writes through to C++ (debounced).
7
+ export function useNativeState(key, { debounce = 150 } = {}) {
8
+ const storeRef = useRef(null);
9
+ if (!storeRef.current) storeRef.current = nativeSetting(key);
10
+ const store = storeRef.current;
11
+
12
+ const [value, setLocal] = useState(store.get());
13
+ const timer = useRef(null);
14
+
15
+ useEffect(() => { // C++ -> UI
16
+ const unsub = store.subscribe(setLocal);
17
+ store.load().catch(() => {});
18
+ return unsub;
19
+ }, [store]);
20
+
21
+ const setValue = useCallback((v) => { // UI -> C++ (debounced)
22
+ setLocal(v);
23
+ clearTimeout(timer.current);
24
+ timer.current = setTimeout(() => store.set(v).catch(console.error), debounce);
25
+ }, [store, debounce]);
26
+
27
+ return [value, setValue];
28
+ }
29
+
30
+ export { bridge, nativeSetting } from "../bridge/index.js";
@@ -0,0 +1,31 @@
1
+ // Vue adapter: import { useNativeState } from "@mwguerra/hull/vue";
2
+ import { ref, watch, onScopeDispose } from "vue";
3
+ import { nativeSetting } from "../bridge/native-store.js";
4
+
5
+ // A ref that two-way-binds to a C++-persisted setting.
6
+ // Edits flow down to C++ (debounced); C++ pushes flow up into the ref.
7
+ export function useNativeState(key, { debounce = 150 } = {}) {
8
+ const store = nativeSetting(key);
9
+ const state = ref(store.get());
10
+ let applying = false; // true while applying a C++-originated value (prevents echo write)
11
+ let timer = null;
12
+
13
+ const unsubscribe = store.subscribe((v) => { // C++ -> UI
14
+ if (v === state.value) return;
15
+ applying = true;
16
+ state.value = v;
17
+ queueMicrotask(() => { applying = false; });
18
+ });
19
+
20
+ watch(state, (v) => { // UI -> C++ (debounced)
21
+ if (applying) return;
22
+ clearTimeout(timer);
23
+ timer = setTimeout(() => store.set(v).catch(console.error), debounce);
24
+ });
25
+
26
+ store.load().catch(() => {}); // initial pull
27
+ onScopeDispose(() => { unsubscribe(); clearTimeout(timer); });
28
+ return state;
29
+ }
30
+
31
+ export { bridge, nativeSetting } from "../bridge/index.js";