@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,386 @@
1
+ // Hull host — a single, generic native web-view runtime.
2
+ //
3
+ // Modes:
4
+ // (window) --url <devUrl> | --app <file.html> render the UI in an OS web view
5
+ // (serve) --serve <port> headless HTTP/SSE bridge (browser dev mode)
6
+ // --title --width --height --app-id --debug --inspect
7
+ //
8
+ // Bindings register into a transport-agnostic Dispatcher, then are exposed either over
9
+ // the web view (window mode) or over HTTP/SSE (serve mode). --inspect turns on the dev
10
+ // trace (mirrors every call/reply/event on the "__trace" event for the inspector).
11
+
12
+ // Must precede any libc header so glibc exposes unshare()/CLONE_NEWUSER (Linux sandbox
13
+ // probe). g++ defines this by default; the guard just makes it portable + warning-free.
14
+ #if defined(__linux__) && !defined(_GNU_SOURCE)
15
+ #define _GNU_SOURCE
16
+ #endif
17
+
18
+ #include <iostream>
19
+ #include <fstream>
20
+ #include <sstream>
21
+ #include <string>
22
+ #include <optional>
23
+ #include <thread>
24
+ #include <nlohmann/json.hpp>
25
+
26
+ #include "dispatcher.hpp"
27
+ #include "secure.hpp"
28
+ #include "bindings/http.hpp" // pulls httplib first (Linux X11 macro clash)
29
+ #include "serve.hpp" // httplib HTTP/SSE bridge server
30
+ #include "webview/webview.h"
31
+ #include "bindings/printer.hpp"
32
+ #include "bindings/storage.hpp"
33
+ #include "bindings/credentials.hpp"
34
+ #include "bindings/database.hpp"
35
+ #include "bindings/files.hpp"
36
+
37
+ #ifdef _WIN32
38
+ #include <windows.h>
39
+ #include <gdiplus.h>
40
+ #endif
41
+
42
+ #if defined(__linux__)
43
+ #include <sched.h> // unshare, CLONE_NEWUSER
44
+ #include <sys/wait.h> // waitpid
45
+ #include <unistd.h> // fork, getuid, write, close, readlink
46
+ #include <fcntl.h> // open
47
+ #include <cstdio> // snprintf
48
+ #include <cstdlib> // setenv / getenv / system
49
+ #include <filesystem> // file:// URL + desktop-integration paths
50
+ #include <glib.h> // g_set_prgname (window app-id -> desktop icon)
51
+ #endif
52
+
53
+ using json = nlohmann::json;
54
+
55
+ namespace {
56
+
57
+ // Set the window icon at runtime from an image file (the host is generic/prebuilt, so
58
+ // the icon can't be embedded at compile time). Windows: GDI+ loads PNG/ICO into an
59
+ // HICON. macOS/Linux(GTK4) window icons come from the app bundle / .desktop file, so
60
+ // this is a no-op there (documented).
61
+ #if defined(_WIN32)
62
+ void set_window_icon(webview::webview& w, const std::string& path) {
63
+ auto win = w.window(); // webview 0.12: result<void*>
64
+ if (!win.ok()) return;
65
+ HWND hwnd = static_cast<HWND>(win.value());
66
+ if (!hwnd) return;
67
+ int n = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0);
68
+ if (n <= 0) return;
69
+ std::wstring wpath(n, L'\0');
70
+ MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, &wpath[0], n);
71
+ ULONG_PTR token = 0;
72
+ Gdiplus::GdiplusStartupInput gsi;
73
+ if (Gdiplus::GdiplusStartup(&token, &gsi, nullptr) != Gdiplus::Ok) return;
74
+ Gdiplus::Bitmap bmp(wpath.c_str());
75
+ if (bmp.GetLastStatus() == Gdiplus::Ok) {
76
+ HICON hIcon = nullptr;
77
+ if (bmp.GetHICON(&hIcon) == Gdiplus::Ok && hIcon) {
78
+ SendMessageW(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(hIcon));
79
+ SendMessageW(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(hIcon));
80
+ // hIcon intentionally kept for the process lifetime (the window owns it).
81
+ }
82
+ }
83
+ // GDI+ left initialized so the icon stays valid.
84
+ }
85
+ #else
86
+ void set_window_icon(webview::webview&, const std::string&) { /* set via app bundle / .desktop */ }
87
+ #endif
88
+
89
+ #if defined(__linux__)
90
+ // WebKitGTK isolates its web/network subprocesses with bubblewrap, which needs
91
+ // unprivileged user namespaces. When those are blocked — Ubuntu 24.04 enables
92
+ // kernel.apparmor_restrict_unprivileged_userns by default, and many containers lack
93
+ // them — the web process aborts with "bwrap: setting up uid map: Permission denied".
94
+ //
95
+ // Probe in a child process (so the parent's namespaces are untouched) whether bwrap's
96
+ // rootless setup will work: create a user namespace AND write its uid map. The map
97
+ // write is the step that actually fails on Ubuntu 24.04 — its
98
+ // apparmor_restrict_unprivileged_userns lets unshare(CLONE_NEWUSER) succeed but denies
99
+ // writing /proc/self/uid_map ("bwrap: setting up uid map: Permission denied"), so a
100
+ // probe that only tries unshare reports a false positive. If this fails, disable the
101
+ // WebKitGTK sandbox so the app still runs — unless the user made an explicit choice:
102
+ // WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS set -> respect it (CLI/launcher opt-out)
103
+ // HULL_FORCE_SANDBOX set -> keep the sandbox, never auto-disable
104
+ bool userns_available() {
105
+ pid_t pid = fork();
106
+ if (pid < 0) return true; // can't probe — assume the sandbox works
107
+ if (pid == 0) {
108
+ if (unshare(CLONE_NEWUSER) != 0) _exit(1);
109
+ int fd = open("/proc/self/uid_map", O_WRONLY);
110
+ if (fd < 0) _exit(1);
111
+ char buf[64];
112
+ int n = std::snprintf(buf, sizeof(buf), "0 %d 1\n", (int)getuid());
113
+ ssize_t w = write(fd, buf, (size_t)n); // EPERM here => bwrap would fail too
114
+ close(fd);
115
+ _exit(w == (ssize_t)n ? 0 : 1);
116
+ }
117
+ int status = 0;
118
+ if (waitpid(pid, &status, 0) < 0) return true;
119
+ return WIFEXITED(status) && WEXITSTATUS(status) == 0;
120
+ }
121
+
122
+ void maybe_disable_webkit_sandbox() {
123
+ if (std::getenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS")) return; // explicit opt-out
124
+ if (std::getenv("HULL_FORCE_SANDBOX")) return; // forced on
125
+ if (userns_available()) return; // sandbox will work
126
+ setenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1", 1);
127
+ std::cerr << "hull: unprivileged user namespaces are unavailable; disabling the "
128
+ "WebKitGTK sandbox so the app can run.\n"
129
+ " To keep the sandbox, enable userns (e.g. sudo sysctl "
130
+ "kernel.apparmor_restrict_unprivileged_userns=0) or set "
131
+ "HULL_FORCE_SANDBOX=1.\n";
132
+ }
133
+
134
+ // GTK4 has no runtime "set window icon from a PNG" (unlike Windows GDI+): the title
135
+ // bar / dock / Alt-Tab icon is drawn by the compositor from a .desktop file matched to
136
+ // the window's app-id. So to show an icon on Linux we (1) install the PNG into the user
137
+ // icon theme, (2) write a .desktop whose Icon points at it, and (3) set the window's
138
+ // app-id (via g_set_prgname, before GTK init) to that .desktop's id so they're matched.
139
+ // Works on both Wayland and X11. Idempotent; writes only under XDG data home.
140
+ void install_desktop_integration(const std::string& app_id, const std::string& title,
141
+ const std::string& icon_path) {
142
+ if (app_id.empty()) return;
143
+ std::error_code ec;
144
+ const char* home = std::getenv("HOME");
145
+ const char* xdg = std::getenv("XDG_DATA_HOME");
146
+ std::filesystem::path data = (xdg && *xdg) ? std::filesystem::path(xdg)
147
+ : std::filesystem::path(home ? home : ".") / ".local" / "share";
148
+
149
+ // Detect an installed binary (e.g. from a .deb under /opt or /usr). The package
150
+ // already ships a *visible* /usr/share/applications/<app-id>.desktop + icon, so we must
151
+ // NOT write a user-level NoDisplay entry — a ~/.local one overrides the system file and
152
+ // would hide the app from the menu. Set the app-id (for window<->.desktop matching) and
153
+ // remove any stale dev-created user entry so the installed app shows up.
154
+ {
155
+ char buf[4096]; ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
156
+ std::string p = n > 0 ? std::string(buf, (size_t)n) : "";
157
+ if (p.rfind("/usr/", 0) == 0 || p.rfind("/opt/", 0) == 0) {
158
+ const std::filesystem::path stale = data / "applications" / (app_id + ".desktop");
159
+ std::ifstream in(stale);
160
+ std::string c((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
161
+ in.close();
162
+ if (c.find("X-Hull-Generated=true") != std::string::npos) std::filesystem::remove(stale, ec);
163
+ g_set_prgname(app_id.c_str());
164
+ return;
165
+ }
166
+ }
167
+
168
+ // (1) icon -> ~/.local/share/icons/hicolor/256x256/apps/<app-id>.png
169
+ if (!icon_path.empty() && std::filesystem::exists(icon_path, ec)) {
170
+ auto icondir = data / "icons" / "hicolor" / "256x256" / "apps";
171
+ std::filesystem::create_directories(icondir, ec);
172
+ std::filesystem::copy_file(icon_path, icondir / (app_id + ".png"),
173
+ std::filesystem::copy_options::overwrite_existing, ec);
174
+ }
175
+
176
+ // (2) ~/.local/share/applications/<app-id>.desktop
177
+ auto appsdir = data / "applications";
178
+ std::filesystem::create_directories(appsdir, ec);
179
+ std::string exe;
180
+ { char buf[4096]; ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
181
+ if (n > 0) { buf[n] = '\0'; exe = buf; } }
182
+ std::ofstream f(appsdir / (app_id + ".desktop"), std::ios::trunc);
183
+ if (f) {
184
+ f << "[Desktop Entry]\n"
185
+ << "Type=Application\n"
186
+ << "Name=" << (title.empty() ? app_id : title) << "\n"
187
+ << "Icon=" << app_id << "\n"
188
+ << "Exec=" << (exe.empty() ? app_id : exe) << "\n"
189
+ << "StartupWMClass=" << app_id << "\n"
190
+ << "NoDisplay=true\n" // matched for the running window, hidden from menus
191
+ << "X-Hull-Generated=true\n";
192
+ }
193
+
194
+ // (3) match the running window to that .desktop via the Wayland/X11 app-id.
195
+ g_set_prgname(app_id.c_str());
196
+ }
197
+
198
+ // Build a percent-encoded file:// URL from a local path. On WebKitGTK we load the
199
+ // packaged app.html by URL (not set_html): set_html gives the document a null base
200
+ // URL, under which <script type="module"> — what the single-file bundle uses — does
201
+ // not execute, so the app renders blank. A real file:// origin runs the modules.
202
+ std::string file_uri(const std::string& path) {
203
+ std::error_code ec;
204
+ std::filesystem::path abs = std::filesystem::absolute(path, ec);
205
+ const std::string p = ec ? path : abs.string();
206
+ static const char* hex = "0123456789ABCDEF";
207
+ std::string out = "file://";
208
+ for (unsigned char c : p) {
209
+ if (c == '/' || c == '-' || c == '_' || c == '.' || c == '~' ||
210
+ (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
211
+ out += static_cast<char>(c);
212
+ } else {
213
+ out += '%'; out += hex[c >> 4]; out += hex[c & 0x0F];
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+ #endif
219
+
220
+ struct Options {
221
+ std::optional<std::string> url;
222
+ std::optional<std::string> app;
223
+ std::optional<int> serve_port;
224
+ std::optional<int> inspect_port;
225
+ std::optional<std::string> icon;
226
+ std::string title = "Hull App";
227
+ std::string appId = "Hull";
228
+ int width = 1100;
229
+ int height = 760;
230
+ bool debug = false;
231
+ bool inspect = false;
232
+ };
233
+
234
+ std::optional<std::string> next_arg(int argc, char** argv, int& i) {
235
+ if (i + 1 < argc) return std::string(argv[++i]);
236
+ return std::nullopt;
237
+ }
238
+
239
+ Options parse_args(int argc, char** argv) {
240
+ Options o;
241
+ for (int i = 1; i < argc; ++i) {
242
+ std::string a = argv[i];
243
+ if (a == "--url") { if (auto v = next_arg(argc, argv, i)) o.url = *v; }
244
+ else if (a == "--app") { if (auto v = next_arg(argc, argv, i)) o.app = *v; }
245
+ else if (a == "--serve") { if (auto v = next_arg(argc, argv, i)) o.serve_port = std::stoi(*v); }
246
+ else if (a == "--inspect-port") { if (auto v = next_arg(argc, argv, i)) o.inspect_port = std::stoi(*v); }
247
+ else if (a == "--title") { if (auto v = next_arg(argc, argv, i)) o.title = *v; }
248
+ else if (a == "--app-id") { if (auto v = next_arg(argc, argv, i)) o.appId = *v; }
249
+ else if (a == "--icon") { if (auto v = next_arg(argc, argv, i)) o.icon = *v; }
250
+ else if (a == "--width") { if (auto v = next_arg(argc, argv, i)) o.width = std::stoi(*v); }
251
+ else if (a == "--height") { if (auto v = next_arg(argc, argv, i)) o.height = std::stoi(*v); }
252
+ else if (a == "--debug") { o.debug = true; }
253
+ else if (a == "--inspect") { o.inspect = true; }
254
+ }
255
+ return o;
256
+ }
257
+
258
+ std::optional<std::string> read_file(const std::string& path) {
259
+ std::ifstream f(path, std::ios::binary);
260
+ if (!f) return std::nullopt;
261
+ std::ostringstream ss;
262
+ ss << f.rdbuf();
263
+ return ss.str();
264
+ }
265
+
266
+ const char* FALLBACK_HTML =
267
+ "<!doctype html><html><head><meta charset='utf-8'><title>Hull</title>"
268
+ "<style>html,body{margin:0;height:100%;font-family:system-ui,sans-serif;"
269
+ "display:flex;align-items:center;justify-content:center;background:#0f172a;color:#e2e8f0}"
270
+ ".c{max-width:34rem;padding:2rem;text-align:center}h1{font-size:1.5rem;margin:.2rem 0}"
271
+ "code{background:#1e293b;padding:.15rem .4rem;border-radius:.3rem;color:#93c5fd}"
272
+ "p{color:#94a3b8;line-height:1.5}</style></head><body><div class='c'>"
273
+ "<h1>\xE2\x9B\xB5 Hull</h1>"
274
+ "<p>No app was provided. Run <code>hull dev</code> during development, "
275
+ "or <code>hull build</code> to package your UI.</p></div></body></html>";
276
+
277
+ void register_all(Dispatcher& d, const Options& opt) {
278
+ d.on("ping", [](const json& a, Reply reply) {
279
+ reply(json{{"ok", true}, {"echo", a.empty() ? json(nullptr) : a.at(0)}});
280
+ });
281
+ d.on("appInfo", [opt](const json&, Reply reply) {
282
+ reply(json{{"ok", true}, {"appId", opt.appId}, {"secure", secure::active()}});
283
+ });
284
+ register_http_bindings(d);
285
+ register_printer_bindings(d);
286
+ register_storage_bindings(d);
287
+ register_credentials_bindings(d);
288
+ register_database_bindings(d);
289
+ register_files_bindings(d);
290
+ }
291
+
292
+ } // namespace
293
+
294
+ #ifdef _WIN32
295
+ int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) {
296
+ int argc = __argc;
297
+ char** argv = __argv;
298
+ #else
299
+ int main(int argc, char** argv) {
300
+ #endif
301
+ Options opt = parse_args(argc, argv);
302
+ storage::set_app_name(opt.appId);
303
+
304
+ Dispatcher d;
305
+ if (opt.inspect) d.set_trace(true);
306
+ register_all(d, opt);
307
+
308
+ // ---- Serve mode: headless HTTP/SSE bridge (browser dev mode) ----
309
+ if (opt.serve_port) {
310
+ try {
311
+ BridgeServer server(d);
312
+ d.set_emit_sink([&server](const std::string& e, const json& p) { server.broadcast(e, p); });
313
+ server.listen("127.0.0.1", *opt.serve_port); // blocks
314
+ } catch (const std::exception& e) {
315
+ std::cerr << e.what() << '\n';
316
+ return 1;
317
+ }
318
+ return 0;
319
+ }
320
+
321
+ // ---- Window mode: render in the OS web view ----
322
+ try {
323
+ #if defined(__linux__)
324
+ maybe_disable_webkit_sandbox(); // before any GTK/WebKit init (fork is safe here)
325
+ if (opt.icon) install_desktop_integration(opt.appId, opt.title, *opt.icon);
326
+ #endif
327
+ webview::webview window(opt.debug, nullptr);
328
+ window.set_title(opt.title);
329
+ window.set_size(opt.width, opt.height, WEBVIEW_HINT_NONE);
330
+ if (opt.icon) set_window_icon(window, *opt.icon);
331
+
332
+ // Optional trace server: lets the inspector (a browser tab) observe this native
333
+ // app's bridge activity. Leaked intentionally — lives for the whole process.
334
+ BridgeServer* trace = opt.inspect_port ? new BridgeServer(d) : nullptr;
335
+
336
+ // emit -> push into the page; also mirror to the inspector trace server if on.
337
+ d.set_emit_sink([&window, trace](const std::string& event, const json& payload) {
338
+ const std::string js =
339
+ "if(window.__bridgeEmit){window.__bridgeEmit(" +
340
+ json(event).dump() + "," + json(payload.dump()).dump() + ");}";
341
+ window.dispatch([&window, js] { window.eval(js); });
342
+ if (trace) trace->broadcast(event, payload);
343
+ });
344
+
345
+ if (trace) {
346
+ const int port = *opt.inspect_port;
347
+ std::thread([trace, port] { trace->listen("127.0.0.1", port); }).detach();
348
+ }
349
+
350
+ // bind every dispatcher handler onto window.<name>
351
+ for (const auto& name : d.names()) {
352
+ window.bind(
353
+ name,
354
+ [&d, &window, name](const std::string& id, const std::string& args_str, void*) {
355
+ json args;
356
+ try { args = json::parse(args_str); } catch (...) { args = json::array(); }
357
+ d.invoke(name, args, [&window, id](const json& res) {
358
+ window.resolve(id, 0, res.dump());
359
+ });
360
+ },
361
+ nullptr);
362
+ }
363
+
364
+ if (opt.url) {
365
+ window.navigate(*opt.url);
366
+ } else if (opt.app) {
367
+ #if defined(__linux__)
368
+ // Load by file:// URL so module scripts run (set_html's null base blocks them
369
+ // on WebKitGTK). Fall back to set_html only if the file is missing.
370
+ if (std::filesystem::exists(*opt.app)) window.navigate(file_uri(*opt.app));
371
+ else window.set_html(FALLBACK_HTML);
372
+ #else
373
+ if (auto html = read_file(*opt.app)) window.set_html(*html);
374
+ else window.set_html(FALLBACK_HTML);
375
+ #endif
376
+ } else {
377
+ window.set_html(FALLBACK_HTML);
378
+ }
379
+
380
+ window.run();
381
+ } catch (const webview::exception& e) {
382
+ std::cerr << e.what() << '\n';
383
+ return 1;
384
+ }
385
+ return 0;
386
+ }
@@ -0,0 +1,62 @@
1
+ #pragma once
2
+ // App identity + per-user storage location. Deliberately free of webview/OpenSSL so
3
+ // it can be shared by storage.hpp (encryption) and db_core.hpp (SQLite), and unit-
4
+ // tested standalone.
5
+ #include <string>
6
+ #include <filesystem>
7
+ #include <system_error>
8
+ #include <cstdlib>
9
+
10
+ namespace fs = std::filesystem;
11
+
12
+ namespace storage {
13
+
14
+ // App identity: namespaces the per-user data dir AND the keychain entries so two
15
+ // Hull apps never clash. Set once at startup from --app-id (see host main.cpp).
16
+ inline std::string& app_name() {
17
+ static std::string n = "Hull";
18
+ return n;
19
+ }
20
+ inline void set_app_name(const std::string& n) {
21
+ if (!n.empty()) app_name() = n;
22
+ }
23
+
24
+ // ---- Resolve and create the per-user data directory ----
25
+ inline fs::path app_data_dir() {
26
+ fs::path base;
27
+ #if defined(_WIN32)
28
+ if (const char* p = std::getenv("LOCALAPPDATA")) base = p;
29
+ else base = fs::temp_directory_path();
30
+ #elif defined(__APPLE__)
31
+ base = fs::path(std::getenv("HOME") ? std::getenv("HOME") : ".")
32
+ / "Library" / "Application Support";
33
+ #else
34
+ if (const char* x = std::getenv("XDG_DATA_HOME")) base = x;
35
+ else base = fs::path(std::getenv("HOME") ? std::getenv("HOME") : ".") / ".local" / "share";
36
+ #endif
37
+ fs::path dir = base / app_name();
38
+ fs::create_directories(dir);
39
+ #if !defined(_WIN32)
40
+ // Owner-only directory (0700). On Windows, %LOCALAPPDATA% is already per-user.
41
+ fs::permissions(dir, fs::perms::owner_all, fs::perm_options::replace);
42
+ #endif
43
+ return dir;
44
+ }
45
+
46
+ // ---- Restrict a path to the owner (POSIX). Files -> 0600; directories -> 0700,
47
+ // because a directory needs the execute (search) bit to create/rename/read entries
48
+ // inside it (without it, writes into the dir fail with EACCES). ----
49
+ inline void lock_down(const fs::path& p) {
50
+ #if !defined(_WIN32)
51
+ std::error_code ec;
52
+ if (fs::exists(p, ec)) {
53
+ const fs::perms perms = fs::is_directory(p, ec)
54
+ ? fs::perms::owner_all // 0700 (rwx) for dirs
55
+ : (fs::perms::owner_read | fs::perms::owner_write); // 0600 (rw-) for files
56
+ fs::permissions(p, perms, fs::perm_options::replace, ec);
57
+ }
58
+ #endif
59
+ (void)p;
60
+ }
61
+
62
+ } // namespace storage
@@ -0,0 +1,124 @@
1
+ #pragma once
2
+ // Pluggable at-rest crypto layer for files + settings (the DB uses SQLCipher under
3
+ // the same flag — see db_core.hpp). Nothing else in the codebase calls crypto
4
+ // directly; everything goes through secure::encrypt / secure::decrypt.
5
+ //
6
+ // Default build -> NullCipher: passthrough, ZERO crypto cost (fast).
7
+ // -DHULL_CRYPTO=ON -> AES-256-GCM with a per-install key in the OS keychain.
8
+ //
9
+ // The on-disk blob is self-describing, so a secure build can still read old
10
+ // plaintext data, and a default build fails loudly on encrypted data:
11
+ // byte 0 = 0x00 -> plaintext follows
12
+ // byte 0 = 0x01 -> AES-256-GCM: [12-byte IV][16-byte tag][ciphertext]
13
+ #include <string>
14
+ #include <optional>
15
+ #include <stdexcept>
16
+
17
+ namespace secure {
18
+
19
+ inline constexpr unsigned char TAG_PLAIN = 0x00;
20
+ inline constexpr unsigned char TAG_AES = 0x01;
21
+
22
+ // true when real crypto is compiled in (the "secure" host build).
23
+ constexpr bool active() {
24
+ #if defined(HULL_CRYPTO)
25
+ return true;
26
+ #else
27
+ return false;
28
+ #endif
29
+ }
30
+
31
+ #if defined(HULL_CRYPTO)
32
+ // ============================ AES-256-GCM backend ============================
33
+ } // namespace secure
34
+ #include <vector>
35
+ #include "paths.hpp" // storage::app_name (namespaces the key)
36
+ #include "keychain.hpp" // secrets:: (per-install key in the OS keychain)
37
+ #include <openssl/evp.h>
38
+ #include <openssl/rand.h>
39
+ namespace secure {
40
+
41
+ inline std::vector<unsigned char> data_key() {
42
+ // One random 32-byte key per install, stored in the keychain (namespaced by app).
43
+ if (auto k = secrets::load(storage::app_name(), "secure-key")) {
44
+ return std::vector<unsigned char>(k->begin(), k->end());
45
+ }
46
+ std::vector<unsigned char> key(32);
47
+ RAND_bytes(key.data(), (int)key.size());
48
+ secrets::store(storage::app_name(), "secure-key", std::string(key.begin(), key.end()));
49
+ return key;
50
+ }
51
+
52
+ inline std::string encrypt(const std::string& plain) {
53
+ auto key = data_key();
54
+ std::vector<unsigned char> iv(12), tag(16), ct(plain.size() + 16);
55
+ RAND_bytes(iv.data(), (int)iv.size());
56
+
57
+ EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
58
+ int len = 0, ct_len = 0;
59
+ EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr);
60
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, (int)iv.size(), nullptr);
61
+ EVP_EncryptInit_ex(ctx, nullptr, nullptr, key.data(), iv.data());
62
+ EVP_EncryptUpdate(ctx, ct.data(), &len,
63
+ reinterpret_cast<const unsigned char*>(plain.data()), (int)plain.size());
64
+ ct_len = len;
65
+ EVP_EncryptFinal_ex(ctx, ct.data() + len, &len);
66
+ ct_len += len;
67
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, (int)tag.size(), tag.data());
68
+ EVP_CIPHER_CTX_free(ctx);
69
+
70
+ std::string out;
71
+ out.push_back((char)TAG_AES);
72
+ out.append(reinterpret_cast<char*>(iv.data()), iv.size());
73
+ out.append(reinterpret_cast<char*>(tag.data()), tag.size());
74
+ out.append(reinterpret_cast<char*>(ct.data()), ct_len);
75
+ return out;
76
+ }
77
+
78
+ inline std::optional<std::string> decrypt(const std::string& blob) {
79
+ if (blob.empty()) return std::string();
80
+ unsigned char tag0 = (unsigned char)blob[0];
81
+ if (tag0 == TAG_PLAIN) return blob.substr(1); // read legacy/plaintext too
82
+ if (tag0 != TAG_AES || blob.size() < 1 + 12 + 16) return std::nullopt;
83
+ auto key = data_key();
84
+ const unsigned char* iv = reinterpret_cast<const unsigned char*>(blob.data() + 1);
85
+ const unsigned char* tag = iv + 12;
86
+ const unsigned char* ct = tag + 16;
87
+ int ct_len = (int)blob.size() - 1 - 12 - 16;
88
+
89
+ std::string out(ct_len, '\0');
90
+ EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
91
+ int len = 0, out_len = 0;
92
+ EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr);
93
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, 12, nullptr);
94
+ EVP_DecryptInit_ex(ctx, nullptr, nullptr, key.data(), iv);
95
+ EVP_DecryptUpdate(ctx, reinterpret_cast<unsigned char*>(&out[0]), &len, ct, ct_len);
96
+ out_len = len;
97
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, const_cast<unsigned char*>(tag));
98
+ int ok = EVP_DecryptFinal_ex(ctx, reinterpret_cast<unsigned char*>(&out[0]) + len, &len);
99
+ EVP_CIPHER_CTX_free(ctx);
100
+ if (ok <= 0) return std::nullopt; // tag mismatch -> tampered or wrong key
101
+ out.resize(out_len + len);
102
+ return out;
103
+ }
104
+
105
+ #else
106
+ // ============================ NullCipher (default) ============================
107
+ inline std::string encrypt(const std::string& plain) {
108
+ std::string out;
109
+ out.reserve(plain.size() + 1);
110
+ out.push_back((char)TAG_PLAIN);
111
+ out += plain;
112
+ return out;
113
+ }
114
+
115
+ inline std::optional<std::string> decrypt(const std::string& blob) {
116
+ if (blob.empty()) return std::string();
117
+ unsigned char tag0 = (unsigned char)blob[0];
118
+ if (tag0 == TAG_PLAIN) return blob.substr(1);
119
+ throw std::runtime_error(
120
+ "data is encrypted — rebuild the host with -DHULL_CRYPTO=ON (secure build)");
121
+ }
122
+ #endif
123
+
124
+ } // namespace secure
@@ -0,0 +1,113 @@
1
+ #pragma once
2
+ // Browser dev-mode transport: exposes the dispatcher over HTTP (UI -> C++) and SSE
3
+ // (C++ -> UI). Reuses cpp-httplib (already linked) — no new dependency. Dev only.
4
+ //
5
+ // POST /bridge/invoke { name, args } -> result json
6
+ // GET /bridge/events text/event-stream of { event, payload } (incl. __trace)
7
+ #include <httplib.h> // before any webview/GTK/X11 headers
8
+ #include <string>
9
+ #include <memory>
10
+ #include <mutex>
11
+ #include <deque>
12
+ #include <set>
13
+ #include <optional>
14
+ #include <future>
15
+ #include <condition_variable>
16
+ #include <atomic>
17
+ #include <chrono>
18
+ #include <nlohmann/json.hpp>
19
+ #include "dispatcher.hpp"
20
+
21
+ using json = nlohmann::json;
22
+
23
+ // One connected Server-Sent-Events client: a thread-safe frame queue.
24
+ class SseClient {
25
+ public:
26
+ void push(const std::string& frame) {
27
+ { std::lock_guard<std::mutex> lk(m_); q_.push_back(frame); }
28
+ cv_.notify_one();
29
+ }
30
+ void close() { closed_ = true; cv_.notify_all(); }
31
+ // next frame, or "" on timeout (write a keepalive), or nullopt when closed.
32
+ std::optional<std::string> pop(int timeout_ms) {
33
+ std::unique_lock<std::mutex> lk(m_);
34
+ if (cv_.wait_for(lk, std::chrono::milliseconds(timeout_ms),
35
+ [&] { return !q_.empty() || closed_; })) {
36
+ if (q_.empty()) return std::nullopt; // closed
37
+ std::string f = q_.front(); q_.pop_front();
38
+ return f;
39
+ }
40
+ return std::string(); // timeout -> keepalive
41
+ }
42
+ private:
43
+ std::mutex m_;
44
+ std::condition_variable cv_;
45
+ std::deque<std::string> q_;
46
+ std::atomic<bool> closed_{false};
47
+ };
48
+
49
+ class BridgeServer {
50
+ public:
51
+ explicit BridgeServer(Dispatcher& d) : d_(d) {}
52
+
53
+ // Push an event/trace frame to every connected SSE client (thread-safe).
54
+ void broadcast(const std::string& event, const json& payload) {
55
+ const std::string frame =
56
+ "data: " + json{{"event", event}, {"payload", payload}}.dump() + "\n\n";
57
+ std::lock_guard<std::mutex> lk(cm_);
58
+ for (auto& c : clients_) c->push(frame);
59
+ }
60
+
61
+ void listen(const std::string& host, int port) {
62
+ httplib::Server svr;
63
+
64
+ // CORS (dev only) — the browser runs at the Vite origin, the host at another port.
65
+ svr.set_post_routing_handler([](const httplib::Request&, httplib::Response& res) {
66
+ res.set_header("Access-Control-Allow-Origin", "*");
67
+ res.set_header("Access-Control-Allow-Headers", "Content-Type");
68
+ });
69
+ svr.Options(R"(.*)", [](const httplib::Request&, httplib::Response& res) { res.status = 204; });
70
+
71
+ svr.Get("/health", [](const httplib::Request&, httplib::Response& res) {
72
+ res.set_content("ok", "text/plain");
73
+ });
74
+
75
+ // UI -> C++
76
+ svr.Post("/bridge/invoke", [this](const httplib::Request& req, httplib::Response& res) {
77
+ json body;
78
+ try { body = json::parse(req.body); }
79
+ catch (...) { res.status = 400; res.set_content(R"({"ok":false,"error":"bad json"})", "application/json"); return; }
80
+ const std::string name = body.value("name", std::string());
81
+ const json args = body.contains("args") ? body["args"] : json::array();
82
+ std::promise<json> p;
83
+ auto fut = p.get_future();
84
+ d_.invoke(name, args, [&p](const json& result) { p.set_value(result); });
85
+ res.set_content(fut.get().dump(), "application/json"); // blocks until the handler replies
86
+ });
87
+
88
+ // C++ -> UI (events + dev trace)
89
+ svr.Get("/bridge/events", [this](const httplib::Request&, httplib::Response& res) {
90
+ auto client = std::make_shared<SseClient>();
91
+ { std::lock_guard<std::mutex> lk(cm_); clients_.insert(client); }
92
+ res.set_chunked_content_provider(
93
+ "text/event-stream",
94
+ [client](size_t, httplib::DataSink& sink) {
95
+ auto f = client->pop(15000);
96
+ if (!f) return false; // closed
97
+ const std::string frame = f->empty() ? std::string(": keepalive\n\n") : *f;
98
+ return sink.write(frame.data(), frame.size());
99
+ },
100
+ [this, client](bool) {
101
+ std::lock_guard<std::mutex> lk(cm_);
102
+ clients_.erase(client);
103
+ });
104
+ });
105
+
106
+ svr.listen(host.c_str(), port);
107
+ }
108
+
109
+ private:
110
+ Dispatcher& d_;
111
+ std::mutex cm_;
112
+ std::set<std::shared_ptr<SseClient>> clients_;
113
+ };