@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.
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/assets/hull-logo.png +0 -0
- package/assets/hull-logo.svg +5 -0
- package/bin/hull.js +4 -0
- package/devtools/dist/index.html +29 -0
- package/host/CMakeLists.txt +101 -0
- package/host/README.md +94 -0
- package/host/linux.Dockerfile +26 -0
- package/host/src/bindings/credentials.hpp +35 -0
- package/host/src/bindings/database.hpp +51 -0
- package/host/src/bindings/files.hpp +58 -0
- package/host/src/bindings/http.hpp +84 -0
- package/host/src/bindings/printer.hpp +281 -0
- package/host/src/bindings/storage.hpp +71 -0
- package/host/src/db_core.hpp +198 -0
- package/host/src/dispatcher.hpp +81 -0
- package/host/src/file_store.hpp +91 -0
- package/host/src/keychain.hpp +157 -0
- package/host/src/main.cpp +386 -0
- package/host/src/paths.hpp +62 -0
- package/host/src/secure.hpp +124 -0
- package/host/src/serve.hpp +113 -0
- package/host/test/db_test.cpp +80 -0
- package/host/test/secure_files_test.cpp +68 -0
- package/host/third_party/sqlite/sqlite3.c +269376 -0
- package/host/third_party/sqlite/sqlite3.h +14347 -0
- package/package.json +58 -0
- package/src/bridge/bridge-core.js +92 -0
- package/src/bridge/index.js +139 -0
- package/src/bridge/native-store.js +34 -0
- package/src/cli/build.js +122 -0
- package/src/cli/config.js +102 -0
- package/src/cli/dev.js +158 -0
- package/src/cli/eject.js +39 -0
- package/src/cli/host.js +61 -0
- package/src/cli/index.js +54 -0
- package/src/cli/installer.js +265 -0
- package/src/cli/release.js +178 -0
- package/src/cli/start.js +45 -0
- package/src/cli/timing.js +22 -0
- package/src/cli/vite.js +16 -0
- package/src/react/index.js +30 -0
- package/src/vue/index.js +31 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mwguerra/hull",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny native desktop apps from your Vue/React UI — a prebuilt C++ web-view host you drive with npm scripts. No compiler, no Electron.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hull": "bin/hull.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./bridge": "./src/bridge/index.js",
|
|
11
|
+
"./vue": "./src/vue/index.js",
|
|
12
|
+
"./react": "./src/react/index.js",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"assets",
|
|
19
|
+
"devtools/dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"host/CMakeLists.txt",
|
|
22
|
+
"host/README.md",
|
|
23
|
+
"host/linux.Dockerfile",
|
|
24
|
+
"host/src",
|
|
25
|
+
"host/test",
|
|
26
|
+
"host/third_party"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"desktop",
|
|
30
|
+
"webview",
|
|
31
|
+
"native",
|
|
32
|
+
"vue",
|
|
33
|
+
"react",
|
|
34
|
+
"cpp",
|
|
35
|
+
"gui",
|
|
36
|
+
"electron-alternative",
|
|
37
|
+
"tauri-alternative"
|
|
38
|
+
],
|
|
39
|
+
"author": "mwguerra",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"archiver": "^7.0.0",
|
|
43
|
+
"vite-plugin-singlefile": "^2.1.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"vite": ">=5"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"vite": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"optionalDependencies": {
|
|
54
|
+
"@mwguerra/hull-win32-x64": "0.1.0",
|
|
55
|
+
"@mwguerra/hull-darwin-arm64": "0.1.0",
|
|
56
|
+
"@mwguerra/hull-linux-x64": "0.1.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Framework-agnostic transport for the native C++ bridge. Auto-selects:
|
|
2
|
+
// - "native": running inside the host web view (window.<binding> functions exist)
|
|
3
|
+
// - "http": running in a normal browser with a Hull dev server
|
|
4
|
+
// (window.__HULL_BRIDGE__ = "http://127.0.0.1:<port>", injected by
|
|
5
|
+
// `hull dev --browser`) — invoke over POST, events over SSE
|
|
6
|
+
// - "none": plain browser, no host (calls reject gracefully)
|
|
7
|
+
//
|
|
8
|
+
// invoke(name, ...args) -> Promise (UI -> C++)
|
|
9
|
+
// on(event, handler) (C++ -> UI; also "__trace" for the inspector)
|
|
10
|
+
|
|
11
|
+
function bridgeUrl() {
|
|
12
|
+
return (typeof globalThis !== "undefined" && globalThis.__HULL_BRIDGE__) || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The HTTP/SSE transport is for browser DEV mode only. Vite replaces import.meta.env.DEV
|
|
16
|
+
// with `false` in `hull build`, so the entire HTTP branch is dead-code-eliminated from
|
|
17
|
+
// the shipped app.html — production apps carry only the native transport.
|
|
18
|
+
const DEV = import.meta.env.DEV;
|
|
19
|
+
|
|
20
|
+
class NativeBridge {
|
|
21
|
+
constructor() {
|
|
22
|
+
this._handlers = new Map(); // event -> Set<handler>
|
|
23
|
+
this._url = bridgeUrl();
|
|
24
|
+
this.mode = this._detectMode();
|
|
25
|
+
|
|
26
|
+
if (this.mode === "native") {
|
|
27
|
+
// The C++ side pushes events via eval(window.__bridgeEmit(event, jsonString)).
|
|
28
|
+
window.__bridgeEmit = (event, jsonString) => {
|
|
29
|
+
let payload;
|
|
30
|
+
try { payload = JSON.parse(jsonString); } catch { payload = jsonString; }
|
|
31
|
+
this._dispatch(event, payload);
|
|
32
|
+
};
|
|
33
|
+
} else if (DEV && this.mode === "http") {
|
|
34
|
+
// Inlined (not a method) so the whole block is dead-code-eliminated when
|
|
35
|
+
// DEV is false — production app.html carries no SSE/EventSource code.
|
|
36
|
+
try {
|
|
37
|
+
const es = new EventSource(`${this._url}/bridge/events`);
|
|
38
|
+
es.onmessage = (e) => {
|
|
39
|
+
let msg;
|
|
40
|
+
try { msg = JSON.parse(e.data); } catch { return; }
|
|
41
|
+
if (msg && typeof msg.event === "string") this._dispatch(msg.event, msg.payload);
|
|
42
|
+
};
|
|
43
|
+
es.onerror = () => { /* EventSource auto-reconnects */ };
|
|
44
|
+
this._es = es;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error("hull bridge: SSE connect failed", e);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_detectMode() {
|
|
52
|
+
if (typeof window !== "undefined" && typeof window.ping === "function") return "native";
|
|
53
|
+
if (DEV && this._url) return "http";
|
|
54
|
+
return "none";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
invoke(name, ...args) {
|
|
58
|
+
if (this.mode === "native") {
|
|
59
|
+
const fn = window[name];
|
|
60
|
+
if (typeof fn !== "function") {
|
|
61
|
+
return Promise.reject(new Error(`Native binding "${name}" unavailable`));
|
|
62
|
+
}
|
|
63
|
+
return fn(...args);
|
|
64
|
+
}
|
|
65
|
+
if (DEV && this.mode === "http") {
|
|
66
|
+
return fetch(`${this._url}/bridge/invoke`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
body: JSON.stringify({ name, args }),
|
|
70
|
+
}).then((r) => r.json());
|
|
71
|
+
}
|
|
72
|
+
return Promise.reject(new Error(`Native binding "${name}" unavailable (no host)`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
on(event, handler) {
|
|
76
|
+
if (!this._handlers.has(event)) this._handlers.set(event, new Set());
|
|
77
|
+
this._handlers.get(event).add(handler);
|
|
78
|
+
return () => this.off(event, handler);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
off(event, handler) {
|
|
82
|
+
this._handlers.get(event)?.delete(handler);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_dispatch(event, payload) {
|
|
86
|
+
this._handlers.get(event)?.forEach((h) => {
|
|
87
|
+
try { h(payload); } catch (e) { console.error(`bridge handler "${event}"`, e); }
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const bridge = new NativeBridge(); // singleton
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Public bridge API for app code: import { ping, httpPost, ... } from "@mwguerra/hull/bridge";
|
|
2
|
+
//
|
|
3
|
+
// Every call goes UI -> C++ and returns a Promise. All real work (TLS HTTP,
|
|
4
|
+
// encrypted storage, keychain, printing) happens in the native host.
|
|
5
|
+
|
|
6
|
+
import { bridge } from "./bridge-core.js";
|
|
7
|
+
|
|
8
|
+
export { bridge } from "./bridge-core.js";
|
|
9
|
+
export { nativeSetting } from "./native-store.js";
|
|
10
|
+
|
|
11
|
+
const call = (name, ...args) => bridge.invoke(name, ...args);
|
|
12
|
+
|
|
13
|
+
// "native" (host web view) | "http" (browser + dev server) | "none" (plain browser).
|
|
14
|
+
export const bridgeMode = () => bridge.mode;
|
|
15
|
+
// true when the bridge can reach the backend (native OR browser dev mode).
|
|
16
|
+
export const hasBridge = () => bridge.mode !== "none";
|
|
17
|
+
// true only inside the native host web view.
|
|
18
|
+
export const isNative = () => bridge.mode === "native";
|
|
19
|
+
|
|
20
|
+
// --- Bridge / diagnostics ---
|
|
21
|
+
export const ping = (text) => call("ping", text);
|
|
22
|
+
|
|
23
|
+
// { ok, appId, secure } — `secure` is true when running a crypto-enabled host build.
|
|
24
|
+
export const appInfo = () => call("appInfo");
|
|
25
|
+
|
|
26
|
+
// --- HTTP (TLS, on a C++ worker thread; auth token injected from the keychain) ---
|
|
27
|
+
export const httpPost = (url, body) => call("httpPost", url, body);
|
|
28
|
+
export const httpGet = (url) => call("httpGet", url);
|
|
29
|
+
|
|
30
|
+
// --- Settings (persisted + AES-256-GCM encrypted at rest) ---
|
|
31
|
+
export const saveSetting = (key, value) => call("saveSetting", key, value);
|
|
32
|
+
export const loadSetting = (key) => call("loadSetting", key);
|
|
33
|
+
export const loadAllSettings = () => call("loadAllSettings");
|
|
34
|
+
|
|
35
|
+
// --- Credentials (WRITE-ONLY from the UI; secrets never returned to JS) ---
|
|
36
|
+
export const saveCredential = (service, account, secret) =>
|
|
37
|
+
call("saveCredential", service, account, secret);
|
|
38
|
+
export const credentialExists = (service, account) =>
|
|
39
|
+
call("credentialExists", service, account);
|
|
40
|
+
export const eraseCredential = (service, account) =>
|
|
41
|
+
call("eraseCredential", service, account);
|
|
42
|
+
|
|
43
|
+
// --- Printing (Winspool / CUPS) ---
|
|
44
|
+
// printMessage: text document — works with ANY printer (Print to PDF, OneNote, laser).
|
|
45
|
+
// printReceipt / printNetwork: raw ESC/POS for thermal receipt printers (spooler / TCP).
|
|
46
|
+
export const listPrinters = () => call("listPrinters");
|
|
47
|
+
export const printMessage = (printer, text) => call("printMessage", printer, text);
|
|
48
|
+
export const printReceipt = (printer, text) => call("printReceipt", printer, text);
|
|
49
|
+
export const printNetwork = (host, port, text) => call("printNetwork", host, port, text);
|
|
50
|
+
|
|
51
|
+
// --- SQLite (parameterized; stored in the per-user app dir) ---
|
|
52
|
+
// Ergonomic wrapper: unwraps the bridge envelope and throws on error, so you can
|
|
53
|
+
// use plain try/catch. Always pass values via the `params` array (never string-
|
|
54
|
+
// concatenate) — they're bound in C++, which is what makes it injection-safe.
|
|
55
|
+
async function dbCall(method, ...args) {
|
|
56
|
+
const res = await call(method, ...args);
|
|
57
|
+
if (!res?.ok) throw new Error(res?.error ?? `${method} failed`);
|
|
58
|
+
return res;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const db = {
|
|
62
|
+
// INSERT/UPDATE/DELETE/DDL (one statement). -> { changes, lastInsertRowid }
|
|
63
|
+
async exec(sql, params = []) {
|
|
64
|
+
const r = await dbCall("dbExec", sql, params);
|
|
65
|
+
return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
|
|
66
|
+
},
|
|
67
|
+
// SELECT -> array of row objects
|
|
68
|
+
async query(sql, params = []) {
|
|
69
|
+
return (await dbCall("dbQuery", sql, params)).rows;
|
|
70
|
+
},
|
|
71
|
+
// SELECT first row -> row object or null
|
|
72
|
+
async get(sql, params = []) {
|
|
73
|
+
return (await dbCall("dbGet", sql, params)).row;
|
|
74
|
+
},
|
|
75
|
+
// Run several { sql, params } atomically (one transaction). -> results[]
|
|
76
|
+
async batch(statements) {
|
|
77
|
+
return (await dbCall("dbBatch", statements)).results;
|
|
78
|
+
},
|
|
79
|
+
// Apply ordered, run-once migrations. `steps` is an array of SQL strings (or
|
|
80
|
+
// { sql }); step index i is schema version i+1, tracked via PRAGMA user_version.
|
|
81
|
+
async migrate(steps) {
|
|
82
|
+
const row = await this.get("PRAGMA user_version");
|
|
83
|
+
const current = row?.user_version ?? 0;
|
|
84
|
+
if (current >= steps.length) return current;
|
|
85
|
+
const stmts = [];
|
|
86
|
+
for (let i = current; i < steps.length; i++) {
|
|
87
|
+
stmts.push({ sql: typeof steps[i] === "string" ? steps[i] : steps[i].sql });
|
|
88
|
+
}
|
|
89
|
+
stmts.push({ sql: `PRAGMA user_version = ${steps.length}` });
|
|
90
|
+
await this.batch(stmts);
|
|
91
|
+
return steps.length;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// --- Files (uploads/blobs; stored per-user; passed through the secure layer) ---
|
|
96
|
+
function bytesToBase64(bytes) {
|
|
97
|
+
let bin = "";
|
|
98
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
99
|
+
return btoa(bin);
|
|
100
|
+
}
|
|
101
|
+
function base64ToBytes(b64) {
|
|
102
|
+
const bin = atob(b64);
|
|
103
|
+
const out = new Uint8Array(bin.length);
|
|
104
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const files = {
|
|
109
|
+
// content: string (UTF-8) | Uint8Array | ArrayBuffer | Blob
|
|
110
|
+
async write(name, content) {
|
|
111
|
+
let bytes;
|
|
112
|
+
if (typeof content === "string") bytes = new TextEncoder().encode(content);
|
|
113
|
+
else if (content instanceof Uint8Array) bytes = content;
|
|
114
|
+
else if (content instanceof ArrayBuffer) bytes = new Uint8Array(content);
|
|
115
|
+
else if (typeof Blob !== "undefined" && content instanceof Blob)
|
|
116
|
+
bytes = new Uint8Array(await content.arrayBuffer());
|
|
117
|
+
else throw new Error("files.write: content must be a string, Uint8Array, ArrayBuffer, or Blob");
|
|
118
|
+
const res = await call("fileWrite", name, bytesToBase64(bytes));
|
|
119
|
+
if (!res?.ok) throw new Error(res?.error ?? "fileWrite failed");
|
|
120
|
+
},
|
|
121
|
+
async read(name) {
|
|
122
|
+
const res = await call("fileRead", name);
|
|
123
|
+
if (!res?.ok) throw new Error(res?.error ?? "fileRead failed");
|
|
124
|
+
return base64ToBytes(res.data); // Uint8Array
|
|
125
|
+
},
|
|
126
|
+
async readText(name) {
|
|
127
|
+
return new TextDecoder().decode(await this.read(name));
|
|
128
|
+
},
|
|
129
|
+
async list() {
|
|
130
|
+
const res = await call("fileList");
|
|
131
|
+
if (!res?.ok) throw new Error(res?.error ?? "fileList failed");
|
|
132
|
+
return res.files; // [{ name, size }]
|
|
133
|
+
},
|
|
134
|
+
async remove(name) {
|
|
135
|
+
const res = await call("fileDelete", name);
|
|
136
|
+
if (!res?.ok) throw new Error(res?.error ?? "fileDelete failed");
|
|
137
|
+
return res.removed;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { bridge } from "./bridge-core.js";
|
|
2
|
+
|
|
3
|
+
// A framework-agnostic, two-way store for one setting key.
|
|
4
|
+
// - load(): pull the current value from C++
|
|
5
|
+
// - get(): cached value
|
|
6
|
+
// - set(v): write through to C++ (persisted + encrypted) and update locally
|
|
7
|
+
// - subscribe(cb): observe changes from EITHER direction; returns an unsubscribe fn
|
|
8
|
+
// C++ emits "settings:changed" {key, value} after any successful write, keeping every
|
|
9
|
+
// subscriber in sync (including other components/windows).
|
|
10
|
+
export function nativeSetting(key) {
|
|
11
|
+
let value;
|
|
12
|
+
const subs = new Set();
|
|
13
|
+
const notify = () => subs.forEach((cb) => cb(value));
|
|
14
|
+
|
|
15
|
+
bridge.on("settings:changed", (p) => {
|
|
16
|
+
if (p && p.key === key) { value = p.value; notify(); } // C++ -> store
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
get: () => value,
|
|
21
|
+
async load() {
|
|
22
|
+
const res = await bridge.invoke("loadSetting", key);
|
|
23
|
+
if (res?.ok) { value = res.value; notify(); }
|
|
24
|
+
return value;
|
|
25
|
+
},
|
|
26
|
+
async set(v) {
|
|
27
|
+
value = v; notify(); // optimistic local update
|
|
28
|
+
const res = await bridge.invoke("saveSetting", key, v);
|
|
29
|
+
if (!res?.ok) throw new Error(res?.error ?? "saveSetting failed");
|
|
30
|
+
return value; // C++ echo re-syncs others
|
|
31
|
+
},
|
|
32
|
+
subscribe(cb) { subs.add(cb); return () => subs.delete(cb); },
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/cli/build.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { loadVite } from "./vite.js";
|
|
5
|
+
import { resolveHostFor, currentTarget, KNOWN_TARGETS, binaryName } from "./host.js";
|
|
6
|
+
import {
|
|
7
|
+
parseVersion, sanitize, copyHostFiles, writeLauncher, makeArchive, defaultFormat,
|
|
8
|
+
writeMacApp, makeAppArchive,
|
|
9
|
+
} from "./release.js";
|
|
10
|
+
import { createTimer } from "./timing.js";
|
|
11
|
+
|
|
12
|
+
// Usage:
|
|
13
|
+
// hull build [vX.Y.Z] [--platform <key|all>] [--format zip|tar.gz]
|
|
14
|
+
// No version -> release/development/... ; vX.Y.Z -> release/vX.Y.Z/...
|
|
15
|
+
function parseArgs(args) {
|
|
16
|
+
let version = null, platform = null, format = null;
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
const a = args[i];
|
|
19
|
+
if (a === "--platform") platform = args[++i];
|
|
20
|
+
else if (a === "--format") format = args[++i];
|
|
21
|
+
else if (!a.startsWith("-")) version = a;
|
|
22
|
+
}
|
|
23
|
+
return { ...parseVersion(version), platform, format };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function build(cwd, args, { verbose } = {}) {
|
|
27
|
+
const timer = createTimer(verbose);
|
|
28
|
+
const { label, platform, format } = parseArgs(args);
|
|
29
|
+
const cfg = await loadConfig(cwd);
|
|
30
|
+
timer.step("config loaded");
|
|
31
|
+
|
|
32
|
+
// Which platforms to package?
|
|
33
|
+
let targetKeys;
|
|
34
|
+
if (!platform) targetKeys = [currentTarget()];
|
|
35
|
+
else if (platform === "all") targetKeys = KNOWN_TARGETS;
|
|
36
|
+
else targetKeys = [platform];
|
|
37
|
+
|
|
38
|
+
// Keep only targets whose prebuilt host binary (for the chosen flavor) is present.
|
|
39
|
+
const flavor = cfg.secure ? "secure " : "";
|
|
40
|
+
const hosts = [];
|
|
41
|
+
for (const key of targetKeys) {
|
|
42
|
+
const h = await resolveHostFor(key);
|
|
43
|
+
const binPath = h && (cfg.secure ? h.secureBinary : h.hostBinary);
|
|
44
|
+
if (h && binPath) hosts.push(h);
|
|
45
|
+
else console.warn(`hull build: skipping ${key} (no ${flavor}host binary built)`);
|
|
46
|
+
}
|
|
47
|
+
if (hosts.length === 0) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`no ${flavor}host binary available for: ${targetKeys.join(", ")}.\n` +
|
|
50
|
+
` Build it (npm run build:host${cfg.secure ? ":secure" : ""}), or run on the matching OS / CI.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
timer.step(`resolved ${hosts.length} host(s)`);
|
|
54
|
+
|
|
55
|
+
// Build the single-file UI ONCE, shared by every platform bundle.
|
|
56
|
+
const vite = await loadVite(cwd);
|
|
57
|
+
const { viteSingleFile } = await import("vite-plugin-singlefile");
|
|
58
|
+
timer.step("vite loaded");
|
|
59
|
+
console.log("hull build: bundling UI into a single file…");
|
|
60
|
+
await vite.build({
|
|
61
|
+
root: cwd,
|
|
62
|
+
plugins: [viteSingleFile()],
|
|
63
|
+
build: { outDir: cfg.outDir, cssCodeSplit: false, target: "esnext", emptyOutDir: true },
|
|
64
|
+
logLevel: "warn",
|
|
65
|
+
});
|
|
66
|
+
timer.step("vite single-file build");
|
|
67
|
+
const builtHtml = path.join(cwd, cfg.outDir, "index.html");
|
|
68
|
+
if (!fs.existsSync(builtHtml)) {
|
|
69
|
+
throw new Error(`expected ${path.relative(cwd, builtHtml)} after build — is this a Vite app?`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Assemble + archive one bundle per target.
|
|
73
|
+
const versionRoot = path.join(cwd, cfg.releaseDir, label);
|
|
74
|
+
fs.mkdirSync(versionRoot, { recursive: true });
|
|
75
|
+
const results = [];
|
|
76
|
+
|
|
77
|
+
for (const h of hosts) {
|
|
78
|
+
const binName = binaryName(h.key, cfg.secure);
|
|
79
|
+
const bundleDir = path.join(versionRoot, h.key + (cfg.secure ? "-secure" : ""));
|
|
80
|
+
fs.rmSync(bundleDir, { recursive: true, force: true });
|
|
81
|
+
const rootName = `${sanitize(cfg.title)}-${label}-${h.key}${cfg.secure ? "-secure" : ""}`;
|
|
82
|
+
|
|
83
|
+
// macOS: produce a real .app bundle (Dock/Finder icon, double-click) + tar.gz.
|
|
84
|
+
if (h.key.startsWith("darwin")) {
|
|
85
|
+
fs.mkdirSync(bundleDir, { recursive: true });
|
|
86
|
+
const { appName } = writeMacApp(bundleDir, cfg, h.hostDir, binName, builtHtml, cfg.icon);
|
|
87
|
+
const archivePath = path.join(versionRoot, `${rootName}.tar.gz`);
|
|
88
|
+
const bytes = await makeAppArchive(bundleDir, archivePath, rootName);
|
|
89
|
+
results.push({ key: h.key, archivePath, bytes, app: appName });
|
|
90
|
+
timer.step(`packaged ${h.key} (.app)`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
copyHostFiles(h.hostDir, bundleDir, binName);
|
|
95
|
+
fs.copyFileSync(builtHtml, path.join(bundleDir, "app.html"));
|
|
96
|
+
// Bundle the window icon so the distributed app shows it (no node_modules at runtime).
|
|
97
|
+
let iconName = null;
|
|
98
|
+
if (cfg.icon && fs.existsSync(cfg.icon)) {
|
|
99
|
+
iconName = "icon" + path.extname(cfg.icon);
|
|
100
|
+
fs.copyFileSync(cfg.icon, path.join(bundleDir, iconName));
|
|
101
|
+
}
|
|
102
|
+
const launcher = writeLauncher(bundleDir, h.key, cfg, binName, iconName);
|
|
103
|
+
|
|
104
|
+
const fmt = format ?? defaultFormat(h.key);
|
|
105
|
+
const ext = fmt === "zip" ? "zip" : "tar.gz";
|
|
106
|
+
const archivePath = path.join(versionRoot, `${rootName}.${ext}`);
|
|
107
|
+
const bytes = await makeArchive(bundleDir, archivePath, fmt, rootName, h.key, launcher.name, binName);
|
|
108
|
+
results.push({ key: h.key, archivePath, bytes });
|
|
109
|
+
timer.step(`packaged ${h.key}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Summary.
|
|
113
|
+
const rel = (p) => path.relative(cwd, p).replace(/\\/g, "/");
|
|
114
|
+
console.log(`\nhull build: ${label} -> ${rel(versionRoot)}/`);
|
|
115
|
+
for (const r of results) {
|
|
116
|
+
console.log(` ${r.key.padEnd(14)} ${rel(r.archivePath)} (${(r.bytes / 1024).toFixed(0)} KB)`);
|
|
117
|
+
}
|
|
118
|
+
if (results.some((r) => r.key === currentTarget())) {
|
|
119
|
+
console.log(` run it locally with "hull start${label === "development" ? "" : " " + label}"`);
|
|
120
|
+
}
|
|
121
|
+
timer.total("\nhull build");
|
|
122
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// The bundled default app icon (the Hull logo), used when .hullrc sets no icon.
|
|
6
|
+
const DEFAULT_ICON = path.resolve(
|
|
7
|
+
path.dirname(fileURLToPath(import.meta.url)), "../../assets/hull-logo.png");
|
|
8
|
+
|
|
9
|
+
// Package-level defaults. A project's .hullrc overrides these per key (the `window`
|
|
10
|
+
// object is merged deeply); with no config file, all defaults apply.
|
|
11
|
+
const DEFAULTS = {
|
|
12
|
+
window: { width: 1100, height: 760 },
|
|
13
|
+
secure: false,
|
|
14
|
+
debug: false,
|
|
15
|
+
outDir: "dist",
|
|
16
|
+
releaseDir: "release",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function readJson(p) {
|
|
20
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Project config: .hullrc (JSON) in the project root, preferred over the older
|
|
24
|
+
// hull.config.json. Returns {} when none is present (=> all package defaults).
|
|
25
|
+
function readProjectConfig(cwd) {
|
|
26
|
+
for (const f of [".hullrc", ".hullrc.json", "hull.config.json"]) {
|
|
27
|
+
const p = path.join(cwd, f);
|
|
28
|
+
if (fs.existsSync(p)) return readJson(p) ?? {};
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function loadConfig(cwd) {
|
|
34
|
+
const pkg = readJson(path.join(cwd, "package.json")) ?? {};
|
|
35
|
+
const bareName = (pkg.name ?? "hull-app").replace(/^@[^/]+\//, "");
|
|
36
|
+
const file = readProjectConfig(cwd);
|
|
37
|
+
const win = { ...DEFAULTS.window, ...(file.window ?? {}) };
|
|
38
|
+
|
|
39
|
+
// Icon: .hullrc window.icon (or top-level icon), resolved against the project; else
|
|
40
|
+
// the bundled Hull logo. Falls back to the default if the configured file is missing.
|
|
41
|
+
const iconCfg = win.icon ?? file.icon;
|
|
42
|
+
let icon = DEFAULT_ICON;
|
|
43
|
+
if (iconCfg) {
|
|
44
|
+
const p = path.resolve(cwd, iconCfg);
|
|
45
|
+
icon = fs.existsSync(p) ? p : DEFAULT_ICON;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Linux WebKitGTK sandbox: true = force on, false = force off, undefined = auto
|
|
49
|
+
// (the host probes for unprivileged user namespaces and disables it only if needed).
|
|
50
|
+
const linuxSandbox = file.linux?.sandbox;
|
|
51
|
+
|
|
52
|
+
// Installer/store metadata. license: SPDX id; publisher: human/developer name.
|
|
53
|
+
const license = file.license ?? pkg.license ?? null;
|
|
54
|
+
const rawAuthor = file.author ?? pkg.author ?? null;
|
|
55
|
+
const publisher = !rawAuthor ? null
|
|
56
|
+
: typeof rawAuthor === "string" ? rawAuthor.replace(/\s*[<(].*$/, "").trim()
|
|
57
|
+
: (rawAuthor.name ?? null);
|
|
58
|
+
const description = file.description ?? pkg.description ?? null;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
appId: file.appId ?? `com.hull.${bareName}`,
|
|
62
|
+
title: win.title ?? file.title ?? pkg.productName ?? bareName,
|
|
63
|
+
width: Number(win.width),
|
|
64
|
+
height: Number(win.height),
|
|
65
|
+
icon,
|
|
66
|
+
secure: Boolean(file.secure ?? DEFAULTS.secure),
|
|
67
|
+
debug: Boolean(file.debug ?? DEFAULTS.debug),
|
|
68
|
+
linuxSandbox: typeof linuxSandbox === "boolean" ? linuxSandbox : undefined,
|
|
69
|
+
license,
|
|
70
|
+
publisher,
|
|
71
|
+
description,
|
|
72
|
+
outDir: file.outDir ?? DEFAULTS.outDir,
|
|
73
|
+
releaseDir: file.releaseDir ?? DEFAULTS.releaseDir,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Environment additions for the spawned host that control the Linux WebKitGTK sandbox.
|
|
78
|
+
// force off (cfg.linuxSandbox === false, or --no-sandbox) -> disable it up front
|
|
79
|
+
// force on (cfg.linuxSandbox === true) -> tell the host to keep it
|
|
80
|
+
// otherwise -> nothing (host auto-detects)
|
|
81
|
+
export function hostEnv(cfg, { noSandbox = false } = {}) {
|
|
82
|
+
const env = {};
|
|
83
|
+
if (noSandbox || cfg.linuxSandbox === false) {
|
|
84
|
+
env.WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS = "1";
|
|
85
|
+
} else if (cfg.linuxSandbox === true) {
|
|
86
|
+
env.HULL_FORCE_SANDBOX = "1";
|
|
87
|
+
}
|
|
88
|
+
return env;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Common host flags derived from config.
|
|
92
|
+
export function hostArgs(cfg) {
|
|
93
|
+
const args = [
|
|
94
|
+
"--title", cfg.title,
|
|
95
|
+
"--app-id", cfg.appId,
|
|
96
|
+
"--width", String(cfg.width),
|
|
97
|
+
"--height", String(cfg.height),
|
|
98
|
+
];
|
|
99
|
+
if (cfg.icon) args.push("--icon", cfg.icon);
|
|
100
|
+
if (cfg.debug) args.push("--debug");
|
|
101
|
+
return args;
|
|
102
|
+
}
|