janela 0.1.2 → 0.3.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 (38) hide show
  1. package/README.md +135 -26
  2. package/bin/janela.mjs +382 -46
  3. package/package.json +3 -3
  4. package/runtime/janela.ts +289 -122
  5. package/shim/wvshim.cc +726 -67
  6. package/templates/index.html +31 -0
  7. package/templates/main.ts +62 -19
  8. package/templates/react/deps.json +4 -0
  9. package/templates/react/files/index.html +11 -0
  10. package/templates/react/files/janela.conf.json +10 -0
  11. package/templates/react/files/src/App.css +3 -0
  12. package/templates/react/files/src/App.jsx +38 -0
  13. package/templates/react/files/src/main.jsx +9 -0
  14. package/templates/react/files/src-host/main.ts +42 -0
  15. package/templates/react/files/vite.config.js +6 -0
  16. package/templates/solid/deps.json +4 -0
  17. package/templates/solid/files/index.html +11 -0
  18. package/templates/solid/files/janela.conf.json +10 -0
  19. package/templates/solid/files/src/App.css +3 -0
  20. package/templates/solid/files/src/App.jsx +35 -0
  21. package/templates/solid/files/src/main.jsx +4 -0
  22. package/templates/solid/files/src-host/main.ts +42 -0
  23. package/templates/solid/files/vite.config.js +6 -0
  24. package/templates/svelte/deps.json +7 -0
  25. package/templates/svelte/files/index.html +11 -0
  26. package/templates/svelte/files/janela.conf.json +10 -0
  27. package/templates/svelte/files/src/App.svelte +33 -0
  28. package/templates/svelte/files/src/main.js +4 -0
  29. package/templates/svelte/files/src-host/main.ts +42 -0
  30. package/templates/svelte/files/svelte.config.js +3 -0
  31. package/templates/svelte/files/vite.config.js +6 -0
  32. package/templates/vue/deps.json +4 -0
  33. package/templates/vue/files/index.html +11 -0
  34. package/templates/vue/files/janela.conf.json +10 -0
  35. package/templates/vue/files/src/App.vue +39 -0
  36. package/templates/vue/files/src/main.js +4 -0
  37. package/templates/vue/files/src-host/main.ts +42 -0
  38. package/templates/vue/files/vite.config.js +6 -0
package/shim/wvshim.cc CHANGED
@@ -1,26 +1,42 @@
1
- // wvshim.cc — a C-ABI shim over webview.h shaped to scriptc's FFI format 2.
1
+ // wvshim.cc — a C-ABI shim over webview.h shaped to scriptc's FFI format 4.
2
2
  //
3
- // Three constraints drive every design decision here:
4
- // 1. scriptc has no pointer/u64 type, so webview_t can never cross the
5
- // boundary. We keep a handle table and hand out int32 indices.
6
- // 2. Callback params/returns are numeric scalars only, so the request JSON
7
- // cannot be passed as a callback argument. We stage it in a shim-side
8
- // buffer and let TS drain it byte-at-a-time via re-entrant FFI calls.
9
- // 3. Callbacks are lifetime:"call" only. webview_bind wants a retained
10
- // callback, which is not expressible. Instead the TS callback is handed
11
- // to wv_run(), which blocks for the whole app lifetime so "call scope"
12
- // and "app lifetime" coincide, and bind dispatch happens inside it.
3
+ // One constraint still drives the design: scriptc has no pointer/u64 type, so
4
+ // webview_t can never cross the boundary. We keep a handle table and hand out
5
+ // int32 indices.
6
+ //
7
+ // What the newer formats removed:
8
+ // * format 3 callback params may be `string`/`bytes`, so a payload crosses
9
+ // into TS as one argument instead of one FFI call per byte. Payloads going
10
+ // the other way ride `string` params on ordinary functions.
11
+ // * format 4 callbacks may be `retained`, so the invoke and tick handlers
12
+ // are registered once and live for the app's lifetime. wv_run() is a plain
13
+ // blocking call again; it no longer has to carry a callback whose "call
14
+ // scope" was standing in for "app lifetime".
13
15
 
14
16
  #include "webview.h"
15
17
 
16
18
  #include <atomic>
17
19
  #include <chrono>
18
20
  #include <cstdint>
21
+ #include <cstdio>
19
22
  #include <cstring>
23
+ #include <filesystem>
24
+ #include <fstream>
25
+ #include <memory>
26
+ #include <mutex>
20
27
  #include <string>
21
28
  #include <thread>
22
29
  #include <vector>
23
30
 
31
+ // Native dialogs need each platform's own toolkit. webview.h has already
32
+ // pulled in the Cocoa bindings (via its own backend headers) and windows.h on
33
+ // Win32; these add only what it does not use itself.
34
+ #if defined(_WIN32)
35
+ #include <commdlg.h>
36
+ #elif !defined(__APPLE__)
37
+ #include <gtk/gtk.h>
38
+ #endif
39
+
24
40
  namespace {
25
41
 
26
42
  struct Bind {
@@ -39,14 +55,17 @@ struct App {
39
55
  bool used = false;
40
56
  std::vector<Bind> binds;
41
57
 
42
- // Callback supplied by TS for the duration of wv_run().
43
- int32_t (*cb)(uint32_t, uint32_t, void *) = nullptr;
44
- void *cb_ctx = nullptr;
58
+ // Retained TS handlers, registered once and valid until the app exits. The
59
+ // request rides in as a (ptr, len) string param.
60
+ int32_t (*on_invoke)(const uint8_t *, size_t, void *) = nullptr;
61
+ void *on_invoke_ctx = nullptr;
62
+ void (*on_tick)(void *) = nullptr;
63
+ void *on_tick_ctx = nullptr;
45
64
 
46
65
  // Staging for the in-flight request.
47
66
  std::string req; // JSON args array from JS
48
67
  std::string cur_id; // webview's call id, needed by webview_return
49
- std::string reply; // response body assembled by TS
68
+ std::string reply; // response body handed over by TS in one call
50
69
  uint32_t seq = 0;
51
70
 
52
71
  // ---- async support ----
@@ -57,10 +76,6 @@ struct App {
57
76
  std::atomic<int32_t> tick_ms{16};
58
77
  };
59
78
 
60
- // Bind index handed to the TS callback for a timer tick rather than an
61
- // invoke. Real bind indices are small sequential integers.
62
- const uint32_t TICK_BIND = 0xffffffffu;
63
-
64
79
  // Fixed-size table: handles are indices, never pointers.
65
80
  App g_apps[8];
66
81
 
@@ -74,14 +89,506 @@ std::string to_str(const uint8_t *p, size_t n) {
74
89
  return std::string(reinterpret_cast<const char *>(p), n);
75
90
  }
76
91
 
77
- // The single C trampoline registered with webview_bind for every binding.
78
- // `arg` encodes the app index and bind index.
92
+ // ---- jobs ------------------------------------------------------------------
93
+ //
94
+ // A job is any unit of work whose answer cannot be produced during the FFI
95
+ // call that asks for it. TS starts one, gets an id back immediately, and polls
96
+ // wv_job_status() from its tick loop until the job is terminal.
97
+ //
98
+ // Two kinds use this pool, for opposite reasons:
99
+ // * file I/O — the blocking syscall must happen off the UI thread, so a
100
+ // worker thread does it. A worker touches only its own job and NEVER calls
101
+ // into TS: scriptc's runtime is not thread-safe, so the result is drained
102
+ // later, on the UI thread.
103
+ // * native dialogs — the modal must run ON the UI thread, but not while TS
104
+ // is on the stack (runModal/gtk_dialog_run spin a nested event loop, which
105
+ // would re-enter the tick handler underneath the invoke handler that asked
106
+ // for the dialog). So the job is posted with webview_dispatch and runs at
107
+ // the top of a later turn, with no TS frame beneath it.
108
+
109
+ const int32_t JOB_PENDING = 0;
110
+ const int32_t JOB_OK = 1;
111
+ const int32_t JOB_ERROR = 2;
112
+
113
+ struct Job {
114
+ // Written by the producer (worker thread, or the dispatched dialog) before
115
+ // `status` flips; read by the UI thread only after it observes a terminal
116
+ // status. The release/acquire pair on `status` is what publishes `data`, so
117
+ // no lock is needed for the payload itself.
118
+ std::atomic<int32_t> status{JOB_PENDING};
119
+ std::string data; // payload on success, the error message on failure
120
+ std::thread worker; // unused by dialog jobs, which run on the UI thread
121
+ bool used = false;
122
+ };
123
+
124
+ // Jobs are addressed by index and held behind unique_ptr so the vector may
125
+ // grow without invalidating a worker's pointer to its own job.
126
+ std::mutex g_jobs_mu;
127
+ std::vector<std::unique_ptr<Job>> g_jobs;
128
+
129
+ Job *job_at(int32_t id) {
130
+ std::lock_guard<std::mutex> lock(g_jobs_mu);
131
+ if (id < 0 || static_cast<size_t>(id) >= g_jobs.size()) return nullptr;
132
+ Job *j = g_jobs[id].get();
133
+ return j->used ? j : nullptr;
134
+ }
135
+
136
+ // Reuses a finished slot when one is free, so a long-running app that reads
137
+ // many files does not grow the table without bound.
138
+ int32_t new_job() {
139
+ std::lock_guard<std::mutex> lock(g_jobs_mu);
140
+ for (size_t i = 0; i < g_jobs.size(); i++) {
141
+ if (g_jobs[i]->used) continue;
142
+ if (g_jobs[i]->worker.joinable()) g_jobs[i]->worker.join();
143
+ g_jobs[i]->status.store(JOB_PENDING);
144
+ g_jobs[i]->data.clear();
145
+ g_jobs[i]->used = true;
146
+ return static_cast<int32_t>(i);
147
+ }
148
+ g_jobs.push_back(std::unique_ptr<Job>(new Job()));
149
+ g_jobs.back()->used = true;
150
+ return static_cast<int32_t>(g_jobs.size() - 1);
151
+ }
152
+
153
+ // Node-shaped messages: janela apps already surface node:fs errors from
154
+ // synchronous handlers, so the async path should read the same.
155
+ std::string fs_error_message(const std::string &path, const char *op) {
156
+ std::error_code ec;
157
+ auto st = std::filesystem::status(path, ec);
158
+ if (st.type() == std::filesystem::file_type::not_found) {
159
+ return "ENOENT: no such file or directory, " + std::string(op) + " '" +
160
+ path + "'";
161
+ }
162
+ if (st.type() == std::filesystem::file_type::directory) {
163
+ return "EISDIR: illegal operation on a directory, " + std::string(op) +
164
+ " '" + path + "'";
165
+ }
166
+ return "EIO: failed to " + std::string(op) + " '" + path + "'";
167
+ }
168
+
169
+ void job_finish(Job *j, int32_t status, std::string payload) {
170
+ j->data = std::move(payload);
171
+ j->status.store(status, std::memory_order_release);
172
+ }
173
+
174
+ void fs_read_worker(Job *j, std::string path) {
175
+ std::error_code ec;
176
+ if (std::filesystem::is_directory(path, ec)) {
177
+ job_finish(j, JOB_ERROR, fs_error_message(path, "read"));
178
+ return;
179
+ }
180
+ std::ifstream in(path, std::ios::binary);
181
+ if (!in) {
182
+ job_finish(j, JOB_ERROR, fs_error_message(path, "open"));
183
+ return;
184
+ }
185
+ std::string buf((std::istreambuf_iterator<char>(in)),
186
+ std::istreambuf_iterator<char>());
187
+ if (in.bad()) {
188
+ job_finish(j, JOB_ERROR, fs_error_message(path, "read"));
189
+ return;
190
+ }
191
+ job_finish(j, JOB_OK, std::move(buf));
192
+ }
193
+
194
+ void fs_write_worker(Job *j, std::string path, std::string data) {
195
+ std::ofstream out(path, std::ios::binary | std::ios::trunc);
196
+ if (!out) {
197
+ job_finish(j, JOB_ERROR, fs_error_message(path, "open"));
198
+ return;
199
+ }
200
+ out.write(data.data(), static_cast<std::streamsize>(data.size()));
201
+ out.flush();
202
+ if (!out) {
203
+ job_finish(j, JOB_ERROR, fs_error_message(path, "write"));
204
+ return;
205
+ }
206
+ job_finish(j, JOB_OK, std::string());
207
+ }
208
+
209
+ // Join every worker. Called at shutdown so no thread outlives the process's
210
+ // orderly exit (and so nothing writes into a job after main returns).
211
+ void jobs_join_all() {
212
+ std::lock_guard<std::mutex> lock(g_jobs_mu);
213
+ for (size_t i = 0; i < g_jobs.size(); i++) {
214
+ if (g_jobs[i]->worker.joinable()) g_jobs[i]->worker.join();
215
+ }
216
+ }
217
+
218
+ // ---- native file dialogs ----------------------------------------------------
219
+ //
220
+ // Options arrive as plain FFI params rather than JSON so the shim needs no
221
+ // parser. `filters` is "Name|ext,ext|Name|ext" — the separators cannot appear
222
+ // in an extension, and a filter name containing one is the caller's problem.
223
+ // The answer is a JSON array of paths, or the literal `null` for a cancel.
224
+
225
+ const int32_t DLG_OPEN = 0;
226
+ const int32_t DLG_SAVE = 1;
227
+ const int32_t DLG_MULTIPLE = 1; // flags bit 0
228
+ const int32_t DLG_DIRECTORY = 2; // flags bit 1
229
+
230
+ struct DialogRequest {
231
+ int32_t app = -1;
232
+ int32_t job = -1;
233
+ int32_t kind = DLG_OPEN;
234
+ int32_t flags = 0;
235
+ std::string title;
236
+ std::string default_path;
237
+ std::string default_name;
238
+ std::string filters;
239
+ };
240
+
241
+ std::vector<std::string> split_on(const std::string &s, char sep) {
242
+ std::vector<std::string> out;
243
+ if (s.empty()) return out;
244
+ std::string cur;
245
+ for (size_t i = 0; i < s.size(); i++) {
246
+ if (s[i] == sep) {
247
+ out.push_back(cur);
248
+ cur.clear();
249
+ } else {
250
+ cur.push_back(s[i]);
251
+ }
252
+ }
253
+ out.push_back(cur);
254
+ return out;
255
+ }
256
+
257
+ struct Filter {
258
+ std::string name;
259
+ std::vector<std::string> extensions;
260
+ };
261
+
262
+ std::vector<Filter> parse_filters(const std::string &spec) {
263
+ std::vector<Filter> out;
264
+ std::vector<std::string> parts = split_on(spec, '|');
265
+ for (size_t i = 0; i + 1 < parts.size(); i += 2) {
266
+ Filter f;
267
+ f.name = parts[i];
268
+ f.extensions = split_on(parts[i + 1], ',');
269
+ if (!f.extensions.empty()) out.push_back(f);
270
+ }
271
+ return out;
272
+ }
273
+
274
+ // Paths are UTF-8 and JSON strings are UTF-8, so only the structural
275
+ // characters and C0 controls need escaping.
276
+ std::string json_escape(const std::string &s) {
277
+ std::string out;
278
+ out.reserve(s.size() + 2);
279
+ out.push_back('"');
280
+ for (size_t i = 0; i < s.size(); i++) {
281
+ unsigned char c = static_cast<unsigned char>(s[i]);
282
+ switch (c) {
283
+ case '"': out += "\\\""; break;
284
+ case '\\': out += "\\\\"; break;
285
+ case '\n': out += "\\n"; break;
286
+ case '\r': out += "\\r"; break;
287
+ case '\t': out += "\\t"; break;
288
+ default:
289
+ if (c < 0x20) {
290
+ char buf[7];
291
+ std::snprintf(buf, sizeof(buf), "\\u%04x", c);
292
+ out += buf;
293
+ } else {
294
+ out.push_back(static_cast<char>(c));
295
+ }
296
+ }
297
+ }
298
+ out.push_back('"');
299
+ return out;
300
+ }
301
+
302
+ std::string json_array(const std::vector<std::string> &items) {
303
+ std::string out = "[";
304
+ for (size_t i = 0; i < items.size(); i++) {
305
+ if (i) out.push_back(',');
306
+ out += json_escape(items[i]);
307
+ }
308
+ out.push_back(']');
309
+ return out;
310
+ }
311
+
312
+ // Fills `out` and returns true when the user confirmed; returns false for a
313
+ // cancel. `error` is set only for a platform-level refusal.
314
+ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
315
+ std::string &error);
316
+
317
+ #if defined(__APPLE__)
318
+
319
+ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
320
+ std::string &error) {
321
+ (void)error;
322
+ using namespace webview::detail;
323
+ objc::autoreleasepool arp;
324
+
325
+ bool save = req.kind == DLG_SAVE;
326
+ id panel = save ? objc::msg_send<id>(objc::get_class("NSSavePanel"),
327
+ objc::selector("savePanel"))
328
+ : cocoa::NSOpenPanel_openPanel();
329
+ if (!panel) return false;
330
+
331
+ if (!req.title.empty()) {
332
+ objc::msg_send<void>(panel, objc::selector("setTitle:"),
333
+ cocoa::NSString_stringWithUTF8String(req.title));
334
+ }
335
+ if (!req.default_path.empty()) {
336
+ id url = objc::msg_send<id>(
337
+ objc::get_class("NSURL"), objc::selector("fileURLWithPath:"),
338
+ cocoa::NSString_stringWithUTF8String(req.default_path));
339
+ objc::msg_send<void>(panel, objc::selector("setDirectoryURL:"), url);
340
+ }
341
+ if (save && !req.default_name.empty()) {
342
+ objc::msg_send<void>(panel, objc::selector("setNameFieldStringValue:"),
343
+ cocoa::NSString_stringWithUTF8String(req.default_name));
344
+ }
345
+
346
+ std::vector<Filter> filters = parse_filters(req.filters);
347
+ if (!filters.empty()) {
348
+ // setAllowedFileTypes: is deprecated in favour of UTType on macOS 12+, but
349
+ // still honoured, and it takes plain extension strings — the UTType path
350
+ // would need a type lookup per extension for no gain here.
351
+ id types = objc::msg_send<id>(objc::get_class("NSMutableArray"),
352
+ objc::selector("array"));
353
+ for (size_t i = 0; i < filters.size(); i++) {
354
+ for (size_t j = 0; j < filters[i].extensions.size(); j++) {
355
+ const std::string &ext = filters[i].extensions[j];
356
+ if (ext.empty() || ext == "*") continue;
357
+ objc::msg_send<void>(types, objc::selector("addObject:"),
358
+ cocoa::NSString_stringWithUTF8String(ext));
359
+ }
360
+ }
361
+ if (objc::msg_send<NSUInteger>(types, objc::selector("count")) > 0) {
362
+ objc::msg_send<void>(panel, objc::selector("setAllowedFileTypes:"), types);
363
+ }
364
+ }
365
+
366
+ if (!save) {
367
+ bool want_dirs = (req.flags & DLG_DIRECTORY) != 0;
368
+ cocoa::NSOpenPanel_set_canChooseFiles(panel, !want_dirs);
369
+ cocoa::NSOpenPanel_set_canChooseDirectories(panel, want_dirs);
370
+ cocoa::NSOpenPanel_set_allowsMultipleSelection(
371
+ panel, (req.flags & DLG_MULTIPLE) != 0);
372
+ }
373
+
374
+ if (cocoa::NSSavePanel_runModal(panel) != cocoa::NSModalResponseOK) {
375
+ return false;
376
+ }
377
+
378
+ auto path_of = [](id url) -> std::string {
379
+ id path = objc::msg_send<id>(url, objc::selector("path"));
380
+ const char *utf8 = cocoa::NSString_get_UTF8String(path);
381
+ return utf8 ? std::string(utf8) : std::string();
382
+ };
383
+
384
+ if (save) {
385
+ id url = objc::msg_send<id>(panel, objc::selector("URL"));
386
+ if (!url) return false;
387
+ out.push_back(path_of(url));
388
+ return true;
389
+ }
390
+
391
+ id urls = cocoa::NSOpenPanel_get_URLs(panel);
392
+ NSUInteger n = objc::msg_send<NSUInteger>(urls, objc::selector("count"));
393
+ for (NSUInteger i = 0; i < n; i++) {
394
+ id url = objc::msg_send<id>(urls, objc::selector("objectAtIndex:"), i);
395
+ out.push_back(path_of(url));
396
+ }
397
+ return !out.empty();
398
+ }
399
+
400
+ #elif defined(_WIN32)
401
+
402
+ std::string from_wide(const wchar_t *w, int wlen) {
403
+ if (!w || wlen == 0) return std::string();
404
+ int n = WideCharToMultiByte(CP_UTF8, 0, w, wlen, nullptr, 0, nullptr, nullptr);
405
+ if (n <= 0) return std::string();
406
+ std::string out(static_cast<size_t>(n), '\0');
407
+ WideCharToMultiByte(CP_UTF8, 0, w, wlen, &out[0], n, nullptr, nullptr);
408
+ return out;
409
+ }
410
+
411
+ std::wstring to_wide(const std::string &s) {
412
+ if (s.empty()) return std::wstring();
413
+ int n = MultiByteToWideChar(CP_UTF8, 0, s.data(),
414
+ static_cast<int>(s.size()), nullptr, 0);
415
+ if (n <= 0) return std::wstring();
416
+ std::wstring out(static_cast<size_t>(n), L'\0');
417
+ MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), &out[0],
418
+ n);
419
+ return out;
420
+ }
421
+
422
+ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
423
+ std::string &error) {
424
+ if (req.flags & DLG_DIRECTORY) {
425
+ error = "ENOTSUP: directory selection is not implemented on Windows";
426
+ return false;
427
+ }
428
+
429
+ // GetOpenFileNameW/GetSaveFileNameW render the modern common item dialog on
430
+ // Vista and later as long as no hook is installed, so this buys the current
431
+ // look without the COM ceremony of IFileDialog.
432
+ std::vector<Filter> filters = parse_filters(req.filters);
433
+ std::wstring filter_buf;
434
+ for (size_t i = 0; i < filters.size(); i++) {
435
+ std::string patterns;
436
+ for (size_t j = 0; j < filters[i].extensions.size(); j++) {
437
+ if (j) patterns += ";";
438
+ patterns += "*." + filters[i].extensions[j];
439
+ }
440
+ filter_buf += to_wide(filters[i].name + " (" + patterns + ")");
441
+ filter_buf.push_back(L'\0');
442
+ filter_buf += to_wide(patterns);
443
+ filter_buf.push_back(L'\0');
444
+ }
445
+ if (!filter_buf.empty()) filter_buf.push_back(L'\0');
446
+
447
+ // Multi-select returns "dir\0name\0name\0\0", so the buffer must hold more
448
+ // than one MAX_PATH.
449
+ std::vector<wchar_t> file(32768, L'\0');
450
+ std::wstring initial_name = to_wide(req.default_name);
451
+ if (!initial_name.empty() && initial_name.size() < file.size() - 1) {
452
+ std::memcpy(file.data(), initial_name.c_str(),
453
+ (initial_name.size() + 1) * sizeof(wchar_t));
454
+ }
455
+ std::wstring initial_dir = to_wide(req.default_path);
456
+ std::wstring title = to_wide(req.title);
457
+
458
+ OPENFILENAMEW ofn;
459
+ std::memset(&ofn, 0, sizeof(ofn));
460
+ ofn.lStructSize = sizeof(ofn);
461
+ ofn.hwndOwner = nullptr;
462
+ ofn.lpstrFilter = filter_buf.empty() ? nullptr : filter_buf.c_str();
463
+ ofn.lpstrFile = file.data();
464
+ ofn.nMaxFile = static_cast<DWORD>(file.size());
465
+ ofn.lpstrInitialDir = initial_dir.empty() ? nullptr : initial_dir.c_str();
466
+ ofn.lpstrTitle = title.empty() ? nullptr : title.c_str();
467
+ ofn.Flags = OFN_NOCHANGEDIR | OFN_EXPLORER;
468
+
469
+ if (req.kind == DLG_SAVE) {
470
+ ofn.Flags |= OFN_OVERWRITEPROMPT;
471
+ if (!GetSaveFileNameW(&ofn)) return false;
472
+ out.push_back(from_wide(ofn.lpstrFile, -1));
473
+ if (!out.back().empty()) out.back().pop_back(); // trailing NUL from -1
474
+ return true;
475
+ }
476
+
477
+ ofn.Flags |= OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST;
478
+ if (req.flags & DLG_MULTIPLE) ofn.Flags |= OFN_ALLOWMULTISELECT;
479
+ if (!GetOpenFileNameW(&ofn)) return false;
480
+
481
+ // Single selection is one NUL-terminated path; multiple is a directory
482
+ // followed by bare names, all NUL-separated, ending in a double NUL.
483
+ const wchar_t *p = ofn.lpstrFile;
484
+ std::string first = from_wide(p, static_cast<int>(wcslen(p)));
485
+ p += wcslen(p) + 1;
486
+ if (*p == L'\0') {
487
+ out.push_back(first);
488
+ return true;
489
+ }
490
+ while (*p) {
491
+ std::string name = from_wide(p, static_cast<int>(wcslen(p)));
492
+ out.push_back(first + "\\" + name);
493
+ p += wcslen(p) + 1;
494
+ }
495
+ return !out.empty();
496
+ }
497
+
498
+ #else // GTK
499
+
500
+ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
501
+ std::string &error) {
502
+ (void)error;
503
+ bool save = req.kind == DLG_SAVE;
504
+ bool want_dirs = (req.flags & DLG_DIRECTORY) != 0;
505
+ GtkFileChooserAction action =
506
+ save ? GTK_FILE_CHOOSER_ACTION_SAVE
507
+ : (want_dirs ? GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER
508
+ : GTK_FILE_CHOOSER_ACTION_OPEN);
509
+
510
+ GtkWidget *dialog = gtk_file_chooser_dialog_new(
511
+ req.title.empty() ? (save ? "Save" : "Open") : req.title.c_str(), nullptr,
512
+ action, "_Cancel", GTK_RESPONSE_CANCEL,
513
+ save ? "_Save" : "_Open", GTK_RESPONSE_ACCEPT, nullptr);
514
+ if (!dialog) return false;
515
+
516
+ GtkFileChooser *chooser = GTK_FILE_CHOOSER(dialog);
517
+ if (!save && (req.flags & DLG_MULTIPLE)) {
518
+ gtk_file_chooser_set_select_multiple(chooser, TRUE);
519
+ }
520
+ if (save) {
521
+ gtk_file_chooser_set_do_overwrite_confirmation(chooser, TRUE);
522
+ if (!req.default_name.empty()) {
523
+ gtk_file_chooser_set_current_name(chooser, req.default_name.c_str());
524
+ }
525
+ }
526
+ if (!req.default_path.empty()) {
527
+ gtk_file_chooser_set_current_folder(chooser, req.default_path.c_str());
528
+ }
529
+
530
+ std::vector<Filter> filters = parse_filters(req.filters);
531
+ for (size_t i = 0; i < filters.size(); i++) {
532
+ GtkFileFilter *f = gtk_file_filter_new();
533
+ gtk_file_filter_set_name(f, filters[i].name.c_str());
534
+ for (size_t j = 0; j < filters[i].extensions.size(); j++) {
535
+ std::string pattern = "*." + filters[i].extensions[j];
536
+ gtk_file_filter_add_pattern(f, pattern.c_str());
537
+ }
538
+ gtk_file_chooser_add_filter(chooser, f);
539
+ }
540
+
541
+ bool ok = gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT;
542
+ if (ok) {
543
+ if (!save && (req.flags & DLG_MULTIPLE)) {
544
+ GSList *names = gtk_file_chooser_get_filenames(chooser);
545
+ for (GSList *it = names; it; it = it->next) {
546
+ char *path = static_cast<char *>(it->data);
547
+ if (path) {
548
+ out.push_back(path);
549
+ g_free(path);
550
+ }
551
+ }
552
+ g_slist_free(names);
553
+ } else {
554
+ char *path = gtk_file_chooser_get_filename(chooser);
555
+ if (path) {
556
+ out.push_back(path);
557
+ g_free(path);
558
+ }
559
+ }
560
+ }
561
+ gtk_widget_destroy(dialog);
562
+ // Let the destroy actually happen before the modal's caller resumes.
563
+ while (gtk_events_pending()) gtk_main_iteration();
564
+ return ok && !out.empty();
565
+ }
566
+
567
+ #endif
568
+
569
+ // Runs on the UI thread with no TS frame beneath it — see the note on the job
570
+ // pool above for why that matters.
571
+ void dialog_on_ui_thread(webview_t, void *arg) {
572
+ std::unique_ptr<DialogRequest> req(static_cast<DialogRequest *>(arg));
573
+ Job *j = job_at(req->job);
574
+ if (!j) return;
575
+ std::vector<std::string> picked;
576
+ std::string error;
577
+ bool ok = run_file_dialog(*req, picked, error);
578
+ if (!error.empty()) {
579
+ job_finish(j, JOB_ERROR, error);
580
+ return;
581
+ }
582
+ // A cancel is a successful call that answers `null`, not a failure.
583
+ job_finish(j, JOB_OK, ok ? json_array(picked) : "null");
584
+ }
585
+
586
+ // The single C trampoline registered with webview_bind. `arg` is the app
587
+ // index; every binding routes to the one retained invoke handler.
79
588
  void trampoline(const char *id, const char *req, void *arg) {
80
- uintptr_t packed = reinterpret_cast<uintptr_t>(arg);
81
- App *a = &g_apps[packed >> 32];
82
- uint32_t bind_index = static_cast<uint32_t>(packed & 0xffffffffu);
589
+ App *a = &g_apps[reinterpret_cast<uintptr_t>(arg)];
83
590
 
84
- if (!a->cb) return; // no TS handler installed
591
+ if (!a->on_invoke) return; // no TS handler installed
85
592
 
86
593
  a->req = req ? req : "";
87
594
  a->cur_id = id ? id : "";
@@ -89,9 +596,11 @@ void trampoline(const char *id, const char *req, void *arg) {
89
596
  a->deferred = false;
90
597
  a->seq++;
91
598
 
92
- // Re-entrancy: this call lands back in TS, which will call wv_req_byte()
93
- // etc. back into this shim before returning.
94
- int32_t status = a->cb(bind_index, a->seq, a->cb_ctx);
599
+ // Re-entrancy: this call lands back in TS, which calls wv_reply() back into
600
+ // this shim before returning.
601
+ int32_t status = a->on_invoke(
602
+ reinterpret_cast<const uint8_t *>(a->req.data()), a->req.size(),
603
+ a->on_invoke_ctx);
95
604
 
96
605
  // An async handler called wv_defer(): the call id now lives in the pending
97
606
  // table and wv_resolve() will answer the page later. Returning now would
@@ -114,9 +623,9 @@ void trampoline(const char *id, const char *req, void *arg) {
114
623
  // TS it calls stays single-threaded — scriptc's runtime is NOT thread-safe.
115
624
  void tick_on_ui_thread(webview_t, void *arg) {
116
625
  App *a = &g_apps[reinterpret_cast<uintptr_t>(arg)];
117
- if (!a->used || !a->cb) return; // app quit between dispatch and delivery
626
+ if (!a->used || !a->on_tick) return; // app quit between dispatch and delivery
118
627
  a->seq++;
119
- a->cb(TICK_BIND, a->seq, a->cb_ctx);
628
+ a->on_tick(a->on_tick_ctx);
120
629
  }
121
630
 
122
631
  } // namespace
@@ -131,8 +640,10 @@ int32_t wv_create(int32_t debug) {
131
640
  // Field-wise reset: App holds a thread and atomics, so it is not
132
641
  // copy-assignable from a temporary.
133
642
  g_apps[i].binds.clear();
134
- g_apps[i].cb = nullptr;
135
- g_apps[i].cb_ctx = nullptr;
643
+ g_apps[i].on_invoke = nullptr;
644
+ g_apps[i].on_invoke_ctx = nullptr;
645
+ g_apps[i].on_tick = nullptr;
646
+ g_apps[i].on_tick_ctx = nullptr;
136
647
  g_apps[i].req.clear();
137
648
  g_apps[i].cur_id.clear();
138
649
  g_apps[i].reply.clear();
@@ -189,40 +700,20 @@ int32_t wv_eval(int32_t h, const uint8_t *p, size_t n) {
189
700
  int32_t wv_bind(int32_t h, const uint8_t *p, size_t n) {
190
701
  App *a = app_at(h);
191
702
  if (!a) return -1;
192
- uint32_t idx = static_cast<uint32_t>(a->binds.size());
703
+ size_t idx = a->binds.size();
193
704
  a->binds.push_back(Bind{to_str(p, n)});
194
- uintptr_t packed = (static_cast<uintptr_t>(h) << 32) | idx;
195
705
  int rc = webview_bind(a->w, a->binds[idx].name.c_str(), trampoline,
196
- reinterpret_cast<void *>(packed));
706
+ reinterpret_cast<void *>(static_cast<uintptr_t>(h)));
197
707
  if (rc != WEBVIEW_ERROR_OK) return -1;
198
708
  return static_cast<int32_t>(idx);
199
709
  }
200
710
 
201
- // ---- request payload, drained byte-at-a-time from inside the TS callback ----
202
- int32_t wv_req_len(int32_t h) {
203
- App *a = app_at(h);
204
- if (!a) return -1;
205
- return static_cast<int32_t>(a->req.size());
206
- }
207
-
208
- int32_t wv_req_byte(int32_t h, int32_t i) {
209
- App *a = app_at(h);
210
- if (!a || i < 0 || static_cast<size_t>(i) >= a->req.size()) return -1;
211
- return static_cast<uint8_t>(a->req[i]);
212
- }
213
-
214
- // ---- reply payload, pushed byte-at-a-time from inside the TS callback ----
215
- int32_t wv_reply_reset(int32_t h) {
216
- App *a = app_at(h);
217
- if (!a) return -1;
218
- a->reply.clear();
219
- return 0;
220
- }
221
-
222
- int32_t wv_reply_push(int32_t h, int32_t byte) {
711
+ // Stage the response body for the call being handled (or, after wv_defer, for
712
+ // the pending call that wv_resolve will answer). One call, whole payload.
713
+ int32_t wv_reply(int32_t h, const uint8_t *p, size_t n) {
223
714
  App *a = app_at(h);
224
715
  if (!a) return -1;
225
- a->reply.push_back(static_cast<char>(byte & 0xff));
716
+ a->reply.assign(reinterpret_cast<const char *>(p), n);
226
717
  return 0;
227
718
  }
228
719
 
@@ -268,7 +759,7 @@ int32_t wv_resolve(int32_t h, int32_t id, int32_t status) {
268
759
  return 0;
269
760
  }
270
761
 
271
- // Start pumping TS with TICK_BIND callbacks every interval_ms. The thread
762
+ // Start pumping the retained tick handler every interval_ms. The thread
272
763
  // itself only sleeps and posts; all TS execution happens on the UI thread.
273
764
  int32_t wv_tick_start(int32_t h, int32_t interval_ms) {
274
765
  App *a = app_at(h);
@@ -295,19 +786,187 @@ int32_t wv_tick_stop(int32_t h) {
295
786
  return 0;
296
787
  }
297
788
 
298
- // Blocks for the app's lifetime; dispatches bind calls into `cb`.
299
- int32_t wv_run(int32_t h, int32_t (*cb)(uint32_t, uint32_t, void *),
300
- void *cb_ctx) {
789
+ // ---- async file I/O ---------------------------------------------------------
790
+ //
791
+ // wv_fs_read/wv_fs_write start a worker thread and return immediately with a
792
+ // job id. TS polls wv_job_status() from its tick loop and drains the payload
793
+ // with wv_fs_byte() once the job is terminal. On failure the payload is the
794
+ // error message, so success and failure share one drain path.
795
+
796
+ int32_t wv_fs_read(int32_t h, const uint8_t *p, size_t n) {
797
+ if (!app_at(h)) return -1;
798
+ int32_t id = new_job();
799
+ Job *j = job_at(id);
800
+ if (!j) return -1;
801
+ j->worker = std::thread(fs_read_worker, j, to_str(p, n));
802
+ return id;
803
+ }
804
+
805
+ int32_t wv_fs_write(int32_t h, const uint8_t *p, size_t n, const uint8_t *dp,
806
+ size_t dn) {
807
+ if (!app_at(h)) return -1;
808
+ int32_t id = new_job();
809
+ Job *j = job_at(id);
810
+ if (!j) return -1;
811
+ j->worker = std::thread(fs_write_worker, j, to_str(p, n), to_str(dp, dn));
812
+ return id;
813
+ }
814
+
815
+ // 0 = still running, 1 = done, 2 = failed, -1 = no such job.
816
+ int32_t wv_job_status(int32_t h, int32_t id) {
817
+ if (!app_at(h)) return -1;
818
+ Job *j = job_at(id);
819
+ if (!j) return -1;
820
+ return j->status.load(std::memory_order_acquire);
821
+ }
822
+
823
+ // Hand a finished job's payload to TS in one call. The callback is
824
+ // lifetime:"call", so it runs synchronously here — on the UI thread, the only
825
+ // thread allowed to touch the scriptc runtime. The worker is already done by
826
+ // then (the caller has observed a terminal status), so `data` is stable.
827
+ int32_t wv_job_take(int32_t h, int32_t id,
828
+ void (*sink)(const uint8_t *, size_t, void *), void *ctx) {
829
+ if (!app_at(h)) return -1;
830
+ Job *j = job_at(id);
831
+ if (!j || !sink) return -1;
832
+ if (j->status.load(std::memory_order_acquire) == JOB_PENDING) return -1;
833
+ sink(reinterpret_cast<const uint8_t *>(j->data.data()), j->data.size(), ctx);
834
+ return 0;
835
+ }
836
+
837
+ // Release the slot for reuse. Refuses while the worker is still running, so a
838
+ // job's buffer can never be recycled out from under its own thread.
839
+ int32_t wv_job_free(int32_t h, int32_t id) {
840
+ if (!app_at(h)) return -1;
841
+ Job *j = job_at(id);
842
+ if (!j) return -1;
843
+ if (j->status.load(std::memory_order_acquire) == JOB_PENDING) return -1;
844
+ std::lock_guard<std::mutex> lock(g_jobs_mu);
845
+ if (j->worker.joinable()) j->worker.join();
846
+ j->data.clear();
847
+ j->data.shrink_to_fit();
848
+ j->used = false;
849
+ return 0;
850
+ }
851
+
852
+ // ---- native dialogs ---------------------------------------------------------
853
+
854
+ // Start a file dialog. Returns a job id immediately; the modal itself runs on
855
+ // a later UI-thread turn, so this never blocks the invoke that asked for it.
856
+ // The finished payload is a JSON array of paths, or `null` for a cancel.
857
+ int32_t wv_dialog(int32_t h, int32_t kind, int32_t flags, const uint8_t *tp,
858
+ size_t tn, const uint8_t *pp, size_t pn, const uint8_t *np,
859
+ size_t nn, const uint8_t *fp, size_t fn) {
860
+ App *a = app_at(h);
861
+ if (!a) return -1;
862
+ int32_t id = new_job();
863
+ Job *j = job_at(id);
864
+ if (!j) return -1;
865
+
866
+ DialogRequest *req = new DialogRequest();
867
+ req->app = h;
868
+ req->job = id;
869
+ req->kind = kind;
870
+ req->flags = flags;
871
+ req->title = to_str(tp, tn);
872
+ req->default_path = to_str(pp, pn);
873
+ req->default_name = to_str(np, nn);
874
+ req->filters = to_str(fp, fn);
875
+
876
+ if (webview_dispatch(a->w, dialog_on_ui_thread, req) != WEBVIEW_ERROR_OK) {
877
+ delete req;
878
+ job_finish(j, JOB_ERROR, "EIO: could not post the dialog to the UI thread");
879
+ }
880
+ return id;
881
+ }
882
+
883
+ // ---- window control ---------------------------------------------------------
884
+
885
+ int32_t wv_set_fullscreen(int32_t h, int32_t on) {
886
+ App *a = app_at(h);
887
+ if (!a) return -1;
888
+ void *win = webview_get_window(a->w);
889
+ if (!win) return -1;
890
+
891
+ #if defined(__APPLE__)
892
+ using namespace webview::detail;
893
+ objc::autoreleasepool arp;
894
+ id window = static_cast<id>(win);
895
+ // NSWindowStyleMaskFullScreen. toggleFullScreen: only toggles, so read the
896
+ // current state first and leave it alone when it already matches.
897
+ const NSUInteger full = 1UL << 14;
898
+ NSUInteger mask = objc::msg_send<NSUInteger>(window, objc::selector("styleMask"));
899
+ bool is_full = (mask & full) != 0;
900
+ if (is_full != (on != 0)) {
901
+ objc::msg_send<void>(window, objc::selector("toggleFullScreen:"), nullptr);
902
+ }
903
+ return 0;
904
+ #elif defined(_WIN32)
905
+ HWND hwnd = static_cast<HWND>(win);
906
+ static WINDOWPLACEMENT saved = {sizeof(saved), 0, 0, {0, 0}, {0, 0}, {0, 0, 0, 0}};
907
+ LONG_PTR style = GetWindowLongPtrW(hwnd, GWL_STYLE);
908
+ if (on) {
909
+ MONITORINFO mi;
910
+ mi.cbSize = sizeof(mi);
911
+ if (!GetWindowPlacement(hwnd, &saved) ||
912
+ !GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY), &mi)) {
913
+ return -1;
914
+ }
915
+ SetWindowLongPtrW(hwnd, GWL_STYLE, style & ~WS_OVERLAPPEDWINDOW);
916
+ SetWindowPos(hwnd, HWND_TOP, mi.rcMonitor.left, mi.rcMonitor.top,
917
+ mi.rcMonitor.right - mi.rcMonitor.left,
918
+ mi.rcMonitor.bottom - mi.rcMonitor.top,
919
+ SWP_NOOWNERZORDER | SWP_FRAMECHANGED);
920
+ } else {
921
+ SetWindowLongPtrW(hwnd, GWL_STYLE, style | WS_OVERLAPPEDWINDOW);
922
+ SetWindowPlacement(hwnd, &saved);
923
+ SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
924
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER |
925
+ SWP_FRAMECHANGED);
926
+ }
927
+ return 0;
928
+ #else
929
+ GtkWindow *window = GTK_WINDOW(win);
930
+ if (on) {
931
+ gtk_window_fullscreen(window);
932
+ } else {
933
+ gtk_window_unfullscreen(window);
934
+ }
935
+ return 0;
936
+ #endif
937
+ }
938
+
939
+ // Register the retained handler for page invokes. Valid until the app exits.
940
+ int32_t wv_on_invoke(int32_t h,
941
+ int32_t (*cb)(const uint8_t *, size_t, void *),
942
+ void *ctx) {
943
+ App *a = app_at(h);
944
+ if (!a) return -1;
945
+ a->on_invoke = cb;
946
+ a->on_invoke_ctx = ctx;
947
+ return 0;
948
+ }
949
+
950
+ // Register the retained handler the ticker pumps on the UI thread.
951
+ int32_t wv_on_tick(int32_t h, void (*cb)(void *), void *ctx) {
952
+ App *a = app_at(h);
953
+ if (!a) return -1;
954
+ a->on_tick = cb;
955
+ a->on_tick_ctx = ctx;
956
+ return 0;
957
+ }
958
+
959
+ // Blocks for the app's lifetime, dispatching into the retained handlers.
960
+ int32_t wv_run(int32_t h) {
301
961
  App *a = app_at(h);
302
962
  if (!a) return -1;
303
- a->cb = cb;
304
- a->cb_ctx = cb_ctx;
305
963
  int rc = webview_run(a->w);
306
- // The ticker must not outlive the callback it drives.
964
+ // Nothing may call into TS once run() has returned.
307
965
  a->ticking.store(false);
308
966
  if (a->ticker.joinable()) a->ticker.join();
309
- a->cb = nullptr; // callback must not outlive the call, per lifetime:"call"
310
- a->cb_ctx = nullptr;
967
+ jobs_join_all(); // nor may an in-flight read outlive the app
968
+ a->on_invoke = nullptr;
969
+ a->on_tick = nullptr;
311
970
  return rc;
312
971
  }
313
972