janela 0.2.0 → 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 +66 -12
  2. package/bin/janela.mjs +351 -39
  3. package/package.json +1 -1
  4. package/runtime/janela.ts +170 -25
  5. package/shim/wvshim.cc +539 -63
  6. package/templates/index.html +17 -0
  7. package/templates/main.ts +32 -0
  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
@@ -18,6 +18,7 @@
18
18
  #include <atomic>
19
19
  #include <chrono>
20
20
  #include <cstdint>
21
+ #include <cstdio>
21
22
  #include <cstring>
22
23
  #include <filesystem>
23
24
  #include <fstream>
@@ -27,6 +28,15 @@
27
28
  #include <thread>
28
29
  #include <vector>
29
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
+
30
40
  namespace {
31
41
 
32
42
  struct Bind {
@@ -79,54 +89,65 @@ std::string to_str(const uint8_t *p, size_t n) {
79
89
  return std::string(reinterpret_cast<const char *>(p), n);
80
90
  }
81
91
 
82
- // ---- file I/O jobs ---------------------------------------------------------
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.
83
97
  //
84
- // The whole point of this subsystem is that the blocking syscall happens HERE,
85
- // on a worker thread, and never on the UI thread. A worker touches only its
86
- // own job and never calls into TS scriptc's runtime is not thread-safe, so
87
- // results cross back on the UI thread, drained by the tick loop.
88
-
89
- const int32_t FS_PENDING = 0;
90
- const int32_t FS_OK = 1;
91
- const int32_t FS_ERROR = 2;
92
-
93
- struct FsJob {
94
- // Written by the worker before `status` flips; read by the UI thread only
95
- // after it observes a terminal status. The release/acquire pair on `status`
96
- // is what publishes `data`, so no lock is needed for the payload itself.
97
- std::atomic<int32_t> status{FS_PENDING};
98
- std::string data; // file contents on success, the error message on failure
99
- std::thread worker;
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
100
121
  bool used = false;
101
122
  };
102
123
 
103
124
  // Jobs are addressed by index and held behind unique_ptr so the vector may
104
125
  // grow without invalidating a worker's pointer to its own job.
105
- std::mutex g_fs_mu;
106
- std::vector<std::unique_ptr<FsJob>> g_fs_jobs;
126
+ std::mutex g_jobs_mu;
127
+ std::vector<std::unique_ptr<Job>> g_jobs;
107
128
 
108
- FsJob *fs_job_at(int32_t id) {
109
- std::lock_guard<std::mutex> lock(g_fs_mu);
110
- if (id < 0 || static_cast<size_t>(id) >= g_fs_jobs.size()) return nullptr;
111
- FsJob *j = g_fs_jobs[id].get();
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();
112
133
  return j->used ? j : nullptr;
113
134
  }
114
135
 
115
136
  // Reuses a finished slot when one is free, so a long-running app that reads
116
137
  // many files does not grow the table without bound.
117
- int32_t fs_new_job() {
118
- std::lock_guard<std::mutex> lock(g_fs_mu);
119
- for (size_t i = 0; i < g_fs_jobs.size(); i++) {
120
- if (g_fs_jobs[i]->used) continue;
121
- if (g_fs_jobs[i]->worker.joinable()) g_fs_jobs[i]->worker.join();
122
- g_fs_jobs[i]->status.store(FS_PENDING);
123
- g_fs_jobs[i]->data.clear();
124
- g_fs_jobs[i]->used = true;
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;
125
146
  return static_cast<int32_t>(i);
126
147
  }
127
- g_fs_jobs.push_back(std::unique_ptr<FsJob>(new FsJob()));
128
- g_fs_jobs.back()->used = true;
129
- return static_cast<int32_t>(g_fs_jobs.size() - 1);
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);
130
151
  }
131
152
 
132
153
  // Node-shaped messages: janela apps already surface node:fs errors from
@@ -145,53 +166,421 @@ std::string fs_error_message(const std::string &path, const char *op) {
145
166
  return "EIO: failed to " + std::string(op) + " '" + path + "'";
146
167
  }
147
168
 
148
- void fs_finish(FsJob *j, int32_t status, std::string payload) {
169
+ void job_finish(Job *j, int32_t status, std::string payload) {
149
170
  j->data = std::move(payload);
150
171
  j->status.store(status, std::memory_order_release);
151
172
  }
152
173
 
153
- void fs_read_worker(FsJob *j, std::string path) {
174
+ void fs_read_worker(Job *j, std::string path) {
154
175
  std::error_code ec;
155
176
  if (std::filesystem::is_directory(path, ec)) {
156
- fs_finish(j, FS_ERROR, fs_error_message(path, "read"));
177
+ job_finish(j, JOB_ERROR, fs_error_message(path, "read"));
157
178
  return;
158
179
  }
159
180
  std::ifstream in(path, std::ios::binary);
160
181
  if (!in) {
161
- fs_finish(j, FS_ERROR, fs_error_message(path, "open"));
182
+ job_finish(j, JOB_ERROR, fs_error_message(path, "open"));
162
183
  return;
163
184
  }
164
185
  std::string buf((std::istreambuf_iterator<char>(in)),
165
186
  std::istreambuf_iterator<char>());
166
187
  if (in.bad()) {
167
- fs_finish(j, FS_ERROR, fs_error_message(path, "read"));
188
+ job_finish(j, JOB_ERROR, fs_error_message(path, "read"));
168
189
  return;
169
190
  }
170
- fs_finish(j, FS_OK, std::move(buf));
191
+ job_finish(j, JOB_OK, std::move(buf));
171
192
  }
172
193
 
173
- void fs_write_worker(FsJob *j, std::string path, std::string data) {
194
+ void fs_write_worker(Job *j, std::string path, std::string data) {
174
195
  std::ofstream out(path, std::ios::binary | std::ios::trunc);
175
196
  if (!out) {
176
- fs_finish(j, FS_ERROR, fs_error_message(path, "open"));
197
+ job_finish(j, JOB_ERROR, fs_error_message(path, "open"));
177
198
  return;
178
199
  }
179
200
  out.write(data.data(), static_cast<std::streamsize>(data.size()));
180
201
  out.flush();
181
202
  if (!out) {
182
- fs_finish(j, FS_ERROR, fs_error_message(path, "write"));
203
+ job_finish(j, JOB_ERROR, fs_error_message(path, "write"));
183
204
  return;
184
205
  }
185
- fs_finish(j, FS_OK, std::string());
206
+ job_finish(j, JOB_OK, std::string());
186
207
  }
187
208
 
188
209
  // Join every worker. Called at shutdown so no thread outlives the process's
189
210
  // orderly exit (and so nothing writes into a job after main returns).
190
- void fs_join_all() {
191
- std::lock_guard<std::mutex> lock(g_fs_mu);
192
- for (size_t i = 0; i < g_fs_jobs.size(); i++) {
193
- if (g_fs_jobs[i]->worker.joinable()) g_fs_jobs[i]->worker.join();
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;
194
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");
195
584
  }
196
585
 
197
586
  // The single C trampoline registered with webview_bind. `arg` is the app
@@ -400,14 +789,14 @@ int32_t wv_tick_stop(int32_t h) {
400
789
  // ---- async file I/O ---------------------------------------------------------
401
790
  //
402
791
  // wv_fs_read/wv_fs_write start a worker thread and return immediately with a
403
- // job id. TS polls wv_fs_status() from its tick loop and drains the payload
792
+ // job id. TS polls wv_job_status() from its tick loop and drains the payload
404
793
  // with wv_fs_byte() once the job is terminal. On failure the payload is the
405
794
  // error message, so success and failure share one drain path.
406
795
 
407
796
  int32_t wv_fs_read(int32_t h, const uint8_t *p, size_t n) {
408
797
  if (!app_at(h)) return -1;
409
- int32_t id = fs_new_job();
410
- FsJob *j = fs_job_at(id);
798
+ int32_t id = new_job();
799
+ Job *j = job_at(id);
411
800
  if (!j) return -1;
412
801
  j->worker = std::thread(fs_read_worker, j, to_str(p, n));
413
802
  return id;
@@ -416,17 +805,17 @@ int32_t wv_fs_read(int32_t h, const uint8_t *p, size_t n) {
416
805
  int32_t wv_fs_write(int32_t h, const uint8_t *p, size_t n, const uint8_t *dp,
417
806
  size_t dn) {
418
807
  if (!app_at(h)) return -1;
419
- int32_t id = fs_new_job();
420
- FsJob *j = fs_job_at(id);
808
+ int32_t id = new_job();
809
+ Job *j = job_at(id);
421
810
  if (!j) return -1;
422
811
  j->worker = std::thread(fs_write_worker, j, to_str(p, n), to_str(dp, dn));
423
812
  return id;
424
813
  }
425
814
 
426
815
  // 0 = still running, 1 = done, 2 = failed, -1 = no such job.
427
- int32_t wv_fs_status(int32_t h, int32_t id) {
816
+ int32_t wv_job_status(int32_t h, int32_t id) {
428
817
  if (!app_at(h)) return -1;
429
- FsJob *j = fs_job_at(id);
818
+ Job *j = job_at(id);
430
819
  if (!j) return -1;
431
820
  return j->status.load(std::memory_order_acquire);
432
821
  }
@@ -435,24 +824,24 @@ int32_t wv_fs_status(int32_t h, int32_t id) {
435
824
  // lifetime:"call", so it runs synchronously here — on the UI thread, the only
436
825
  // thread allowed to touch the scriptc runtime. The worker is already done by
437
826
  // then (the caller has observed a terminal status), so `data` is stable.
438
- int32_t wv_fs_take(int32_t h, int32_t id,
827
+ int32_t wv_job_take(int32_t h, int32_t id,
439
828
  void (*sink)(const uint8_t *, size_t, void *), void *ctx) {
440
829
  if (!app_at(h)) return -1;
441
- FsJob *j = fs_job_at(id);
830
+ Job *j = job_at(id);
442
831
  if (!j || !sink) return -1;
443
- if (j->status.load(std::memory_order_acquire) == FS_PENDING) return -1;
832
+ if (j->status.load(std::memory_order_acquire) == JOB_PENDING) return -1;
444
833
  sink(reinterpret_cast<const uint8_t *>(j->data.data()), j->data.size(), ctx);
445
834
  return 0;
446
835
  }
447
836
 
448
837
  // Release the slot for reuse. Refuses while the worker is still running, so a
449
838
  // job's buffer can never be recycled out from under its own thread.
450
- int32_t wv_fs_free(int32_t h, int32_t id) {
839
+ int32_t wv_job_free(int32_t h, int32_t id) {
451
840
  if (!app_at(h)) return -1;
452
- FsJob *j = fs_job_at(id);
841
+ Job *j = job_at(id);
453
842
  if (!j) return -1;
454
- if (j->status.load(std::memory_order_acquire) == FS_PENDING) return -1;
455
- std::lock_guard<std::mutex> lock(g_fs_mu);
843
+ if (j->status.load(std::memory_order_acquire) == JOB_PENDING) return -1;
844
+ std::lock_guard<std::mutex> lock(g_jobs_mu);
456
845
  if (j->worker.joinable()) j->worker.join();
457
846
  j->data.clear();
458
847
  j->data.shrink_to_fit();
@@ -460,6 +849,93 @@ int32_t wv_fs_free(int32_t h, int32_t id) {
460
849
  return 0;
461
850
  }
462
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
+
463
939
  // Register the retained handler for page invokes. Valid until the app exits.
464
940
  int32_t wv_on_invoke(int32_t h,
465
941
  int32_t (*cb)(const uint8_t *, size_t, void *),
@@ -488,7 +964,7 @@ int32_t wv_run(int32_t h) {
488
964
  // Nothing may call into TS once run() has returned.
489
965
  a->ticking.store(false);
490
966
  if (a->ticker.joinable()) a->ticker.join();
491
- fs_join_all(); // nor may an in-flight read outlive the app
967
+ jobs_join_all(); // nor may an in-flight read outlive the app
492
968
  a->on_invoke = nullptr;
493
969
  a->on_tick = nullptr;
494
970
  return rc;