@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
|
@@ -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
|
+
};
|