janela 0.12.0 → 0.13.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/README.md CHANGED
@@ -33,7 +33,7 @@ janela dev # build + run with logs in the terminal
33
33
  janela build # .janela/out/my-app (+ my-app.app on macOS)
34
34
  ```
35
35
 
36
- Or start from a frontend framework — any Vite-based one:
36
+ Or start from a frontend framework:
37
37
 
38
38
  ```bash
39
39
  janela init my-app --template vue # or react | svelte | solid | vanilla
@@ -43,8 +43,18 @@ janela dev # Vite dev server + HMR, in a native windo
43
43
 
44
44
  `vanilla` is the default and needs no frontend toolchain at all. With a
45
45
  framework, `janela dev` runs your Vite dev server and points the window at it,
46
- and `janela build` flattens the production bundle into the binary — see
47
- [docs/frontend.md](../../docs/frontend.md).
46
+ and `janela build` flattens the production bundle into the binary.
47
+
48
+ All five templates are built and run on desktop, the iOS simulator and an
49
+ Android emulator — the matrix and sizes are in
50
+ [docs/frontend.md](../../docs/frontend.md). Your own Vite project works too, as
51
+ long as it produces a **single-page `dist`**: multi-entry builds, SSR/SSG
52
+ (Astro, Nuxt) and frameworks with their own non-Vite build are out of scope,
53
+ because the output is flattened into one HTML document.
54
+
55
+ Packaging for distribution — icons, a macOS `.dmg`, Android release signing, and
56
+ what requires an Apple or Google account — is in
57
+ [docs/distribution.md](../../docs/distribution.md).
48
58
 
49
59
  Requirements: Node 24+ and a C++ toolchain for the platform you are building —
50
60
  Xcode CLT on macOS; `g++` + `libwebkit2gtk-4.1-dev` on Linux; an llvm-mingw
package/bin/janela.mjs CHANGED
@@ -351,6 +351,15 @@ function libraryProfile() {
351
351
  params: ["f64", "bool", "string"],
352
352
  returns: "void",
353
353
  },
354
+ // Deliberately its own export rather than reusing onFsDone, whose
355
+ // signature would fit: a dialog result arriving through the file-I/O
356
+ // path would read as a bug for as long as the code lived.
357
+ {
358
+ export: "onDialogDone",
359
+ symbol: `${IOS_PREFIX}on_dialog_done`,
360
+ params: ["f64", "bool", "string"],
361
+ returns: "void",
362
+ },
354
363
  ],
355
364
  // TS -> shell. A channel handler must never re-enter the library (see
356
365
  // upstream #263: violations silently appear to work), so every one of
@@ -362,6 +371,7 @@ function libraryProfile() {
362
371
  { name: "hostSettle", params: ["f64", "string"], returns: "void" },
363
372
  { name: "hostReadFile", params: ["f64", "string"], returns: "void" },
364
373
  { name: "hostWriteFile", params: ["f64", "string", "string"], returns: "void" },
374
+ { name: "hostOpenDialog", params: ["f64", "string"], returns: "void" },
365
375
  ],
366
376
  };
367
377
  }
@@ -1219,6 +1229,9 @@ function build(root, { devUrl = null, gui = true, target = "desktop" } = {}) {
1219
1229
  `/** A file job the shell owns has finished (main queue). */\n` +
1220
1230
  `export function onFsDone(id: number, ok: boolean, payload: string): void {\n` +
1221
1231
  ` app.onFsDone(id, ok, payload);\n` +
1232
+ `}\n` +
1233
+ `export function onDialogDone(id: number, ok: boolean, payload: string): void {\n` +
1234
+ ` app.onDialogDone(id, ok, payload);\n` +
1222
1235
  `}\n`
1223
1236
  : `const app = createApp<CmdsOf<typeof setup>, EvtsOf<typeof setup>>(WINDOW);\n` +
1224
1237
  `setup(app);\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Desktop, iOS and Android apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/ios.ts CHANGED
@@ -42,6 +42,7 @@ declare function hostSchedule(id: number, ms: number): void;
42
42
  declare function hostSettle(pendingId: number, envelopeJson: string): void;
43
43
  declare function hostReadFile(jobId: number, path: string): void;
44
44
  declare function hostWriteFile(jobId: number, path: string, data: string): void;
45
+ declare function hostOpenDialog(jobId: number, optionsJson: string): void;
45
46
 
46
47
  // One job table serves reads and writes; these sentinels say which callback of
47
48
  // the pair is the real one. Identity comparison is the whole trick, so they
@@ -68,9 +69,14 @@ function encode(value: unknown): string {
68
69
  // an answer. A library build still links no event loop (SC4005); that is why
69
70
  // the design routes through the shell rather than a limitation of iOS.
70
71
  //
71
- // What remains behind pending(): the file dialogs (iOS wants a document
72
- // picker, which is its own delegate lifecycle) and window control, which is
73
- // permanently meaningless on a phone rather than unfinished.
72
+ // `openFileDialog` now works too: the shell presents the platform picker and
73
+ // copies the chosen file into the app's container, so the path it hands back
74
+ // is one `readFileAsync` can open.
75
+ //
76
+ // What remains behind pending(): `saveFileDialog`, which is not a missing
77
+ // implementation but an unmade API decision (see its doc comment — mobile
78
+ // wants an export-shaped call, not a path to write to), and window control,
79
+ // which is permanently meaningless on a phone rather than unfinished.
74
80
  //
75
81
  // FAILING LOUDLY WITHOUT KILLING THE APP: an uncaught throw in library mode
76
82
  // reaches the panic sink and then ABORTS the process (SC4013). So a stub must
@@ -135,6 +141,14 @@ export class JanelaAppImpl<
135
141
  jobWriteCbs: ((err: string | null) => void)[] = [];
136
142
  nextJob = 1;
137
143
 
144
+ // In-flight file dialogs. Separate from the file jobs above: a picker is
145
+ // presented by the shell and answered when the user is done, which may be
146
+ // never. Kept in its own table so a dialog result can never be mistaken for
147
+ // a file-I/O result.
148
+ dialogIds: number[] = [];
149
+ dialogCbs: ((paths: string[] | null, err?: string) => void)[] = [];
150
+ nextDialog = 1;
151
+
138
152
  // Kept so the shared WindowConfig shape compiles; iOS has no window to size.
139
153
  constructor(_cfg: WindowConfig) {}
140
154
 
@@ -352,20 +366,80 @@ export class JanelaAppImpl<
352
366
  }
353
367
  }
354
368
 
355
- /** @remarks Not on iOS yet; reports through the callback. */
369
+ /**
370
+ * Present the platform's document picker.
371
+ *
372
+ * The paths handed back are **copies inside the app's own storage**, not the
373
+ * locations the user picked. Neither platform gives an app a durable path to
374
+ * a file outside its container: iOS returns a security-scoped URL that is
375
+ * readable only while its access is held and does not survive a relaunch
376
+ * without a bookmark, and Android returns a `content://` URI that is not a
377
+ * path at all. Copying is what makes the result something `readFileAsync`
378
+ * can open, and keeps this API identical to desktop. Nothing tracks the
379
+ * original afterwards, so a later read sees the file as it was when picked.
380
+ */
356
381
  openFileDialog(
357
- _options: OpenDialogOptions,
382
+ options: OpenDialogOptions,
358
383
  cb: (paths: string[] | null, err?: string) => void,
359
384
  ): void {
360
- cb(null, pending("openFileDialog", "iOS needs a document picker"));
385
+ const id = this.nextDialog;
386
+ this.nextDialog = id + 1;
387
+ this.dialogIds.push(id);
388
+ this.dialogCbs.push(cb);
389
+ hostOpenDialog(id, JSON.stringify(options));
361
390
  }
362
391
 
363
- /** @remarks Not on iOS yet; reports through the callback. */
392
+ /**
393
+ * Called by the shell on the main queue when a picker closes.
394
+ *
395
+ * `ok` false carries an error message in `payload`. `ok` true carries a JSON
396
+ * array of paths — empty when the user cancelled, which answers `null` to
397
+ * match desktop.
398
+ */
399
+ onDialogDone(id: number, ok: boolean, payload: string): void {
400
+ for (let i = 0; i < this.dialogIds.length; i++) {
401
+ if (this.dialogIds[i] === id) {
402
+ const cb = this.dialogCbs[i];
403
+ this.dialogIds.splice(i, 1);
404
+ this.dialogCbs.splice(i, 1);
405
+ if (!ok) {
406
+ cb(null, payload);
407
+ return;
408
+ }
409
+ const paths = JSON.parse(payload) as string[];
410
+ // Cancel is not an error: desktop answers null, so this does too.
411
+ if (paths.length < 1) cb(null);
412
+ else cb(paths);
413
+ return;
414
+ }
415
+ }
416
+ }
417
+
418
+ /**
419
+ * @remarks Not on mobile; reports through the callback.
420
+ *
421
+ * Deliberately still absent while `openFileDialog` works, because the two
422
+ * are not symmetrical here. Desktop's "save" means *tell me where to write*,
423
+ * and neither mobile platform offers that: iOS's `forExporting:` picker
424
+ * requires the file to already exist before it opens, and Android's
425
+ * `ACTION_CREATE_DOCUMENT` hands back a URI to write into rather than a
426
+ * path. Both want an export-shaped call — "here is a file, put it
427
+ * somewhere" — which is a different operation, not a different spelling of
428
+ * this one. Implementing it as `saveFileDialog` would give the same method
429
+ * two meanings across platforms, so it waits for an API decision.
430
+ */
364
431
  saveFileDialog(
365
432
  _options: SaveDialogOptions,
366
433
  cb: (path: string | null, err?: string) => void,
367
434
  ): void {
368
- cb(null, pending("saveFileDialog", "iOS needs a document picker"));
435
+ cb(
436
+ null,
437
+ pending(
438
+ "saveFileDialog",
439
+ "a phone has no 'choose a path to write' picker; iOS and Android both " +
440
+ "want an export-shaped call, which is a different operation",
441
+ ),
442
+ );
369
443
  }
370
444
 
371
445
  /** @remarks No-op on iOS: an app has no window title to set. */
@@ -36,6 +36,8 @@
36
36
  #include <vector>
37
37
 
38
38
  #include <android/log.h>
39
+ #include <sys/stat.h>
40
+ #include <unistd.h>
39
41
 
40
42
  #define JANELA_LOG(...) \
41
43
  __android_log_print(ANDROID_LOG_INFO, "janela", __VA_ARGS__)
@@ -53,6 +55,8 @@ void jl_handle_invoke(const char *cmd, size_t cmd_len, const char *args,
53
55
  void jl_index_html(char **out, size_t *out_len);
54
56
  void jl_on_timer(double id);
55
57
  void jl_on_fs_done(double id, bool ok, const char *payload, size_t payload_len);
58
+ void jl_on_dialog_done(double id, bool ok, const char *payload,
59
+ size_t payload_len);
56
60
  }
57
61
 
58
62
  namespace {
@@ -60,6 +64,112 @@ namespace {
60
64
  webview::webview *g_webview = nullptr;
61
65
  std::string g_files_dir; // the app's private storage, resolved at startup
62
66
 
67
+ // ---- host output -> logcat -------------------------------------------------
68
+ //
69
+ // Android discards a process's stdout and stderr. `setprop log.redirect-stdio
70
+ // true` is refused on API 36, so a host `console.log` simply vanished and the
71
+ // only way to see what the host printed was to render it and screenshot the
72
+ // screen. That is not a debugging story, and it is the same problem iOS had
73
+ // before its os_log tee.
74
+ //
75
+ // So the shell tees stdout and stderr into logcat line by line under stable
76
+ // tags, and still writes to the original descriptors so nothing is lost:
77
+ //
78
+ // adb logcat -s janela-host:V # what the app printed
79
+ // adb logcat -s janela:V janela-stderr:V # shell notices and stderr
80
+ //
81
+ // The library may not create threads; the shell may, and does — one reader per
82
+ // descriptor.
83
+
84
+ /// Replace `fd` with a pipe, forwarding every line to logcat and on to the
85
+ /// original descriptor.
86
+ ///
87
+ /// `tag` differs per descriptor on purpose. Replacing fd 2 captures everything
88
+ /// in the process that writes to stderr, not just the library — on the
89
+ /// emulator the GL driver is extremely chatty there — so host output goes to
90
+ /// `janela-host` and stderr to `janela-stderr`, keeping
91
+ /// `adb logcat -s janela-host:V` a clean view of what the app itself printed.
92
+ void tee_fd_to_logcat(int fd, android_LogPriority prio, const char *label,
93
+ const char *tag) {
94
+ int original = dup(fd);
95
+ if (original < 0) {
96
+ return;
97
+ }
98
+ int fds[2];
99
+ if (pipe(fds) != 0) {
100
+ close(original);
101
+ return;
102
+ }
103
+ if (dup2(fds[1], fd) < 0) {
104
+ close(fds[0]);
105
+ close(fds[1]);
106
+ close(original);
107
+ return;
108
+ }
109
+ close(fds[1]);
110
+
111
+ int read_fd = fds[0];
112
+ // A pipe is not a tty, so stdio switches to full buffering and would hold
113
+ // the library's output until the buffer filled or the process exited. Pin
114
+ // line buffering back on, or a log line only appears long after the event.
115
+ if (fd == STDOUT_FILENO) {
116
+ setvbuf(stdout, nullptr, _IOLBF, 0);
117
+ } else if (fd == STDERR_FILENO) {
118
+ setvbuf(stderr, nullptr, _IOLBF, 0);
119
+ }
120
+
121
+ std::thread([read_fd, original, prio, tag] {
122
+ std::string pending;
123
+ std::vector<char> buf(4096);
124
+ for (;;) {
125
+ ssize_t n = read(read_fd, buf.data(), buf.size());
126
+ if (n <= 0) {
127
+ break; // writer closed, or an unrecoverable error
128
+ }
129
+ // Pass the bytes through untouched first: stdout must behave as before.
130
+ ssize_t off = 0;
131
+ while (off < n) {
132
+ ssize_t w = write(original, buf.data() + off, (size_t)(n - off));
133
+ if (w <= 0) {
134
+ break;
135
+ }
136
+ off += w;
137
+ }
138
+ // Then split into lines: logcat is line-oriented and truncates long
139
+ // records, so one write per line rather than per read.
140
+ pending.append(buf.data(), (size_t)n);
141
+ size_t nl;
142
+ while ((nl = pending.find('\n')) != std::string::npos) {
143
+ std::string line = pending.substr(0, nl);
144
+ pending.erase(0, nl + 1);
145
+ if (!line.empty() && line.back() == '\r') {
146
+ line.pop_back();
147
+ }
148
+ if (!line.empty()) {
149
+ __android_log_write(prio, tag, line.c_str());
150
+ }
151
+ }
152
+ // A very long line with no newline would otherwise grow without bound.
153
+ if (pending.size() > 64 * 1024) {
154
+ __android_log_write(prio, tag, pending.c_str());
155
+ pending.clear();
156
+ }
157
+ }
158
+ if (!pending.empty()) {
159
+ __android_log_write(prio, tag, pending.c_str());
160
+ }
161
+ }).detach();
162
+
163
+ JANELA_LOG("%s is mirrored to logcat under tag %s", label, tag);
164
+ }
165
+
166
+ /// Mirror the library's output into logcat. Called before jl_init(), so
167
+ /// anything setup() prints is already captured.
168
+ void start_logging() {
169
+ tee_fd_to_logcat(STDOUT_FILENO, ANDROID_LOG_INFO, "stdout", "janela-host");
170
+ tee_fd_to_logcat(STDERR_FILENO, ANDROID_LOG_ERROR, "stderr", "janela-stderr");
171
+ }
172
+
63
173
  /// Copy a library-owned result out of its arena and release it. Results live
64
174
  /// until the next jl_reset(), so nothing may hold the pointer past this call.
65
175
  std::string take_result(char *out, size_t out_len) {
@@ -386,6 +496,228 @@ std::string files_dir(JNIEnv *env, jobject activity) {
386
496
 
387
497
  JavaVM *g_vm = nullptr;
388
498
 
499
+ // ---- the document picker ---------------------------------------------------
500
+ //
501
+ // Android hands back a content:// URI, which is not a path and cannot be given
502
+ // to readFileAsync. So the shell copies the picked bytes into the app's own
503
+ // storage and returns that path, which readFileAsync can already open. The
504
+ // copy is a snapshot; nothing tracks the original afterwards.
505
+ //
506
+ // The picker itself is presented from Java: the Storage Access Framework
507
+ // answers on Activity#onActivityResult, and native code cannot receive that
508
+ // (ART's JNI DefineClass is unimplemented). JanelaActivity owns that half and
509
+ // calls back in through the two JNI entry points at the bottom of this file.
510
+
511
+ /// A global ref to the Activity, so the picker can be presented from a later
512
+ /// turn. Local refs do not survive the call that produced them.
513
+ jobject g_activity = nullptr;
514
+
515
+ /// Attach the calling thread if needed and hand back an env for it.
516
+ struct scoped_env {
517
+ JNIEnv *env = nullptr;
518
+ bool detach = false;
519
+ scoped_env() {
520
+ if (!g_vm) {
521
+ return;
522
+ }
523
+ if (g_vm->GetEnv((void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED) {
524
+ if (g_vm->AttachCurrentThread(&env, nullptr) == JNI_OK) {
525
+ detach = true;
526
+ }
527
+ }
528
+ }
529
+ ~scoped_env() {
530
+ if (detach && g_vm) {
531
+ g_vm->DetachCurrentThread();
532
+ }
533
+ }
534
+ };
535
+
536
+ /// Read a content:// URI through the ContentResolver and copy it into the
537
+ /// app's storage, returning the new path (empty on failure).
538
+ std::string copy_uri_into_storage(JNIEnv *env, const std::string &uri_str,
539
+ const std::string &display_name) {
540
+ if (!g_activity) {
541
+ return {};
542
+ }
543
+ jclass uri_cls = env->FindClass("android/net/Uri");
544
+ jmethodID parse = env->GetStaticMethodID(uri_cls, "parse",
545
+ "(Ljava/lang/String;)Landroid/net/Uri;");
546
+ jstring juri = env->NewStringUTF(uri_str.c_str());
547
+ jobject uri = env->CallStaticObjectMethod(uri_cls, parse, juri);
548
+ env->DeleteLocalRef(juri);
549
+ if (!uri || env->ExceptionCheck()) {
550
+ env->ExceptionClear();
551
+ return {};
552
+ }
553
+
554
+ jclass act_cls = env->GetObjectClass(g_activity);
555
+ jmethodID get_resolver = env->GetMethodID(act_cls, "getContentResolver",
556
+ "()Landroid/content/ContentResolver;");
557
+ jobject resolver = env->CallObjectMethod(g_activity, get_resolver);
558
+ if (!resolver || env->ExceptionCheck()) {
559
+ env->ExceptionClear();
560
+ return {};
561
+ }
562
+ jclass res_cls = env->GetObjectClass(resolver);
563
+ jmethodID open = env->GetMethodID(res_cls, "openInputStream",
564
+ "(Landroid/net/Uri;)Ljava/io/InputStream;");
565
+ jobject stream = env->CallObjectMethod(resolver, open, uri);
566
+ if (!stream || env->ExceptionCheck()) {
567
+ env->ExceptionClear();
568
+ return {};
569
+ }
570
+
571
+ // Prefer the name the user saw. A content:// URI's last segment is an opaque
572
+ // document id, so falling back to it produces copies called things like
573
+ // "3A18"; the Activity resolves OpenableColumns.DISPLAY_NAME for us.
574
+ std::string name = display_name;
575
+ if (name.empty()) {
576
+ name = uri_str;
577
+ size_t slash = name.find_last_of("/%");
578
+ if (slash != std::string::npos && slash + 1 < name.size()) {
579
+ name = name.substr(slash + 1);
580
+ }
581
+ }
582
+ // A display name is untrusted input and must not escape the pick directory.
583
+ for (char &c : name) {
584
+ if (c == '/' || c == '\\') {
585
+ c = '_';
586
+ }
587
+ }
588
+ if (name == "." || name == "..") {
589
+ name = "picked";
590
+ }
591
+ for (char &c : name) {
592
+ if (c == ':' || c == '?' || c == '&' || c == '=') {
593
+ c = '_';
594
+ }
595
+ }
596
+ if (name.empty()) {
597
+ name = "picked";
598
+ }
599
+ std::string dir = resolve_path("picked");
600
+ ::mkdir(dir.c_str(), 0755);
601
+ std::string dest = dir + "/" + name;
602
+
603
+ jclass stream_cls = env->GetObjectClass(stream);
604
+ jmethodID read = env->GetMethodID(stream_cls, "read", "([B)I");
605
+ jmethodID close = env->GetMethodID(stream_cls, "close", "()V");
606
+ jbyteArray buf = env->NewByteArray(8192);
607
+ std::ofstream out(dest, std::ios::binary | std::ios::trunc);
608
+ bool ok = out.good();
609
+ while (ok) {
610
+ jint n = env->CallIntMethod(stream, read, buf);
611
+ if (env->ExceptionCheck()) {
612
+ env->ExceptionClear();
613
+ ok = false;
614
+ break;
615
+ }
616
+ if (n <= 0) {
617
+ break;
618
+ }
619
+ jbyte *bytes = env->GetByteArrayElements(buf, nullptr);
620
+ out.write(reinterpret_cast<const char *>(bytes), n);
621
+ env->ReleaseByteArrayElements(buf, bytes, JNI_ABORT);
622
+ }
623
+ out.close();
624
+ env->CallVoidMethod(stream, close);
625
+ env->ExceptionClear();
626
+
627
+ env->DeleteLocalRef(buf);
628
+ env->DeleteLocalRef(stream_cls);
629
+ env->DeleteLocalRef(stream);
630
+ env->DeleteLocalRef(res_cls);
631
+ env->DeleteLocalRef(resolver);
632
+ env->DeleteLocalRef(act_cls);
633
+ env->DeleteLocalRef(uri);
634
+ env->DeleteLocalRef(uri_cls);
635
+
636
+ if (!ok || !out) {
637
+ return {};
638
+ }
639
+ return dest;
640
+ }
641
+
642
+ void finish_dialog(double job, bool ok, std::string payload) {
643
+ // BY VALUE and dispatched: never re-enter the library from inside a JNI
644
+ // callback (upstream #263) — this lands at the top of a later turn.
645
+ if (g_webview) {
646
+ g_webview->dispatch([job, ok, payload] {
647
+ jl_on_dialog_done(job, ok, payload.c_str(), payload.size());
648
+ });
649
+ }
650
+ }
651
+
652
+ /// TS -> shell: present a document picker. Records the request and returns;
653
+ /// the Activity presents it on the UI thread.
654
+ void host_open_dialog(void *, double job, const char *options, size_t len) {
655
+ std::string opts(options, len);
656
+ if (!g_webview) {
657
+ return;
658
+ }
659
+ g_webview->dispatch([job, opts] {
660
+ // Android's SAF has no directory mode through ACTION_OPEN_DOCUMENT;
661
+ // report rather than quietly opening a file picker instead.
662
+ if (webview::detail::json_parse(opts, "directory", 0) == "true") {
663
+ finish_dialog(job, false,
664
+ "ENOTSUP: picking a directory is not supported on Android");
665
+ return;
666
+ }
667
+ scoped_env se;
668
+ if (!se.env || !g_activity) {
669
+ finish_dialog(job, false, "EINVAL: the app has no Activity to present on");
670
+ return;
671
+ }
672
+ JNIEnv *env = se.env;
673
+
674
+ // Filters are MIME types here, so an extension list cannot be honoured
675
+ // directly; a type given as "image/*" or "text/plain" passes through.
676
+ std::vector<std::string> mimes;
677
+ for (size_t i = 0;; i++) {
678
+ std::string ext = webview::detail::json_parse(opts, "extensions", i);
679
+ if (ext.empty()) {
680
+ break;
681
+ }
682
+ if (ext.find('/') != std::string::npos) {
683
+ mimes.push_back(ext);
684
+ } else {
685
+ JANELA_LOG("filter '.%s' is an extension, but Android's picker takes "
686
+ "MIME types, so the picker is not filtered",
687
+ ext.c_str());
688
+ }
689
+ }
690
+
691
+ jclass str_cls = env->FindClass("java/lang/String");
692
+ jobjectArray arr =
693
+ env->NewObjectArray((jsize)mimes.size(), str_cls, nullptr);
694
+ for (size_t i = 0; i < mimes.size(); i++) {
695
+ jstring s = env->NewStringUTF(mimes[i].c_str());
696
+ env->SetObjectArrayElement(arr, (jsize)i, s);
697
+ env->DeleteLocalRef(s);
698
+ }
699
+
700
+ jclass act_cls = env->GetObjectClass(g_activity);
701
+ jmethodID mid = env->GetMethodID(act_cls, "openDocumentPicker",
702
+ "(DZ[Ljava/lang/String;)V");
703
+ if (!mid) {
704
+ env->ExceptionClear();
705
+ finish_dialog(job, false,
706
+ "EIO: the Activity has no openDocumentPicker method");
707
+ } else {
708
+ bool multiple = webview::detail::json_parse(opts, "multiple", 0) == "true";
709
+ env->CallVoidMethod(g_activity, mid, (jdouble)job, (jboolean)multiple, arr);
710
+ if (env->ExceptionCheck()) {
711
+ env->ExceptionClear();
712
+ finish_dialog(job, false, "EIO: could not open a document picker");
713
+ }
714
+ }
715
+ env->DeleteLocalRef(arr);
716
+ env->DeleteLocalRef(str_cls);
717
+ env->DeleteLocalRef(act_cls);
718
+ });
719
+ }
720
+
389
721
  } // namespace
390
722
 
391
723
  extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
@@ -393,6 +725,46 @@ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
393
725
  return JNI_VERSION_1_6;
394
726
  }
395
727
 
728
+ extern "C" JNIEXPORT void JNICALL
729
+ Java_dev_janela_host_JanelaActivity_nativeOnDialogResult(JNIEnv *env, jclass,
730
+ jdouble job,
731
+ jboolean ok,
732
+ jstring payload) {
733
+ std::string p;
734
+ if (payload) {
735
+ const char *chars = env->GetStringUTFChars(payload, nullptr);
736
+ if (chars) {
737
+ p = chars;
738
+ env->ReleaseStringUTFChars(payload, chars);
739
+ }
740
+ }
741
+ finish_dialog((double)job, ok == JNI_TRUE, p);
742
+ }
743
+
744
+ extern "C" JNIEXPORT jstring JNICALL
745
+ Java_dev_janela_host_JanelaActivity_nativeCopyUri(JNIEnv *env, jclass,
746
+ jstring uri,
747
+ jstring display_name) {
748
+ std::string u;
749
+ if (uri) {
750
+ const char *chars = env->GetStringUTFChars(uri, nullptr);
751
+ if (chars) {
752
+ u = chars;
753
+ env->ReleaseStringUTFChars(uri, chars);
754
+ }
755
+ }
756
+ std::string dn;
757
+ if (display_name) {
758
+ const char *chars = env->GetStringUTFChars(display_name, nullptr);
759
+ if (chars) {
760
+ dn = chars;
761
+ env->ReleaseStringUTFChars(display_name, chars);
762
+ }
763
+ }
764
+ std::string path = copy_uri_into_storage(env, u, dn);
765
+ return env->NewStringUTF(path.c_str());
766
+ }
767
+
396
768
  extern "C" JNIEXPORT void JNICALL
397
769
  Java_dev_janela_host_JanelaActivity_nativeOnCreate(JNIEnv *env, jclass,
398
770
  jobject activity) {
@@ -401,6 +773,12 @@ Java_dev_janela_host_JanelaActivity_nativeOnCreate(JNIEnv *env, jclass,
401
773
  return;
402
774
  }
403
775
  g_files_dir = files_dir(env, activity);
776
+ // A global ref: the picker is presented from a later turn, and a local
777
+ // ref does not survive the call that produced it.
778
+ g_activity = env->NewGlobalRef(activity);
779
+
780
+ // Before jl_init(), so anything setup() prints reaches logcat.
781
+ start_logging();
404
782
 
405
783
  // Registration is a pure store and is legal before init; the panic sink and
406
784
  // every channel must be in place before any TypeScript runs, since setup()
@@ -411,6 +789,8 @@ Java_dev_janela_host_JanelaActivity_nativeOnCreate(JNIEnv *env, jclass,
411
789
  jl_set_callback("hostReadFile", (void (*)(void))host_read_file, nullptr) ||
412
790
  jl_set_callback("hostWriteFile", (void (*)(void))host_write_file,
413
791
  nullptr) ||
792
+ jl_set_callback("hostOpenDialog", (void (*)(void))host_open_dialog,
793
+ nullptr) ||
414
794
  jl_set_callback("janelaEmit", (void (*)(void))emit_event, nullptr)) {
415
795
  JANELA_LOG("could not register a host channel");
416
796
  }
@@ -1,6 +1,8 @@
1
1
  package dev.janela.host;
2
2
 
3
3
  import android.app.Activity;
4
+ import android.content.Intent;
5
+ import android.net.Uri;
4
6
  import android.os.Bundle;
5
7
 
6
8
  /**
@@ -10,17 +12,159 @@ import android.os.Bundle;
10
12
  * Looper. Everything janela does starts from onCreate, which hands the
11
13
  * Activity to the native shell — the mirror of the iOS shell, where UIKit owns
12
14
  * the loop and the app's TypeScript is a linked scriptc library we call into.
15
+ *
16
+ * <p>The file picker also lives here rather than in native code, because the
17
+ * Storage Access Framework is reached through {@code startActivityForResult}
18
+ * and answered on an Activity method. Native code cannot receive that: ART's
19
+ * JNI {@code DefineClass} is unimplemented, so a class the framework can call
20
+ * back into has to exist in the APK. The shell asks this class to open a
21
+ * picker and gets the answer back through {@code nativeOnDialogResult}.
13
22
  */
14
23
  public final class JanelaActivity extends Activity {
15
24
  static {
16
25
  System.loadLibrary("janela");
17
26
  }
18
27
 
28
+ /** Matches the request code the shell passes; only one picker at a time. */
29
+ private static final int PICK_REQUEST = 0x4a41;
30
+
19
31
  private static native void nativeOnCreate(Activity activity);
20
32
 
33
+ /**
34
+ * Hands a picker result to the shell.
35
+ *
36
+ * @param job the shell's dialog job id
37
+ * @param ok false when the pick failed outright
38
+ * @param payload a JSON array of copied paths, or an error message
39
+ */
40
+ private static native void nativeOnDialogResult(double job, boolean ok, String payload);
41
+
42
+ /**
43
+ * Resolves a content:// URI into a file inside the app's storage.
44
+ *
45
+ * <p>Implemented natively so the copy, the JSON and the error strings live
46
+ * in one place shared with iOS, rather than being written twice.
47
+ */
48
+ private static native String nativeCopyUri(String uri, String displayName);
49
+
50
+ private double pendingJob = -1;
51
+
21
52
  @Override
22
53
  protected void onCreate(Bundle savedInstanceState) {
23
54
  super.onCreate(savedInstanceState);
24
55
  nativeOnCreate(this);
25
56
  }
57
+
58
+ /**
59
+ * Called from the shell (on the UI thread) to present a document picker.
60
+ *
61
+ * @param job the dialog job id to answer with
62
+ * @param multiple allow more than one selection
63
+ * @param mimeTypes MIME types to filter on, or an empty array for anything
64
+ */
65
+ public void openDocumentPicker(double job, boolean multiple, String[] mimeTypes) {
66
+ if (pendingJob >= 0) {
67
+ nativeOnDialogResult(job, false, "EBUSY: a file dialog is already open on this app");
68
+ return;
69
+ }
70
+ try {
71
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
72
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
73
+ // A concrete type is required; the extras narrow it when filters exist.
74
+ intent.setType("*/*");
75
+ if (mimeTypes != null && mimeTypes.length > 0) {
76
+ intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
77
+ }
78
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
79
+ pendingJob = job;
80
+ startActivityForResult(intent, PICK_REQUEST);
81
+ } catch (Throwable t) {
82
+ pendingJob = -1;
83
+ nativeOnDialogResult(job, false, "EIO: could not open a document picker: " + t);
84
+ }
85
+ }
86
+
87
+ @Override
88
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
89
+ super.onActivityResult(requestCode, resultCode, data);
90
+ if (requestCode != PICK_REQUEST) {
91
+ return;
92
+ }
93
+ double job = pendingJob;
94
+ pendingJob = -1;
95
+ if (job < 0) {
96
+ return;
97
+ }
98
+ // Cancel is not an error: an empty array becomes null on the page, which
99
+ // is what desktop answers.
100
+ if (resultCode != Activity.RESULT_OK || data == null) {
101
+ nativeOnDialogResult(job, true, "[]");
102
+ return;
103
+ }
104
+
105
+ StringBuilder json = new StringBuilder("[");
106
+ try {
107
+ if (data.getClipData() != null) {
108
+ int n = data.getClipData().getItemCount();
109
+ for (int i = 0; i < n; i++) {
110
+ Uri uri = data.getClipData().getItemAt(i).getUri();
111
+ if (!appendCopied(json, uri, job)) {
112
+ return;
113
+ }
114
+ }
115
+ } else if (data.getData() != null) {
116
+ if (!appendCopied(json, data.getData(), job)) {
117
+ return;
118
+ }
119
+ }
120
+ } catch (Throwable t) {
121
+ nativeOnDialogResult(job, false, "EIO: could not read the picked selection: " + t);
122
+ return;
123
+ }
124
+ json.append("]");
125
+ nativeOnDialogResult(job, true, json.toString());
126
+ }
127
+
128
+ /**
129
+ * The file's own name, so a copy is not named after the URI.
130
+ *
131
+ * A content:// URI's last segment is an opaque document id — picking
132
+ * "pickme.txt" yielded a copy called "3A18" before this. OpenableColumns
133
+ * carries the name the user actually saw.
134
+ */
135
+ private String displayName(Uri uri) {
136
+ try (android.database.Cursor c =
137
+ getContentResolver().query(uri, null, null, null, null)) {
138
+ if (c != null && c.moveToFirst()) {
139
+ int i = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME);
140
+ if (i >= 0) {
141
+ String name = c.getString(i);
142
+ if (name != null && !name.isEmpty()) {
143
+ return name;
144
+ }
145
+ }
146
+ }
147
+ } catch (Throwable ignored) {
148
+ // Fall through: the shell names the copy from the URI instead.
149
+ }
150
+ return "";
151
+ }
152
+
153
+ /** Copies one URI and appends its quoted path; false if it failed. */
154
+ private boolean appendCopied(StringBuilder json, Uri uri, double job) {
155
+ if (uri == null) {
156
+ return true;
157
+ }
158
+ String copied = nativeCopyUri(uri.toString(), displayName(uri));
159
+ if (copied == null || copied.isEmpty()) {
160
+ nativeOnDialogResult(
161
+ job, false, "EIO: could not copy the picked file into the app's storage");
162
+ return false;
163
+ }
164
+ if (json.length() > 1) {
165
+ json.append(",");
166
+ }
167
+ json.append("\"").append(copied.replace("\\", "\\\\").replace("\"", "\\\"")).append("\"");
168
+ return true;
169
+ }
26
170
  }
package/shim/ios/app.cc CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  #include <dispatch/dispatch.h>
20
20
  #include <os/log.h>
21
+ #include <sys/stat.h>
21
22
  #include <unistd.h>
22
23
 
23
24
  #include <cstdio>
@@ -42,6 +43,8 @@ void jl_handle_invoke(const char *cmd, size_t cmd_len, const char *args,
42
43
  void jl_index_html(char **out, size_t *out_len);
43
44
  void jl_on_timer(double id);
44
45
  void jl_on_fs_done(double id, bool ok, const char *payload, size_t payload_len);
46
+ void jl_on_dialog_done(double id, bool ok, const char *payload,
47
+ size_t payload_len);
45
48
  }
46
49
 
47
50
  namespace {
@@ -305,6 +308,275 @@ void host_write_file(void *, double id, const char *path, size_t path_len,
305
308
  });
306
309
  }
307
310
 
311
+ // ---- the document picker ---------------------------------------------------
312
+ //
313
+ // A picked file cannot simply be handed to readFileAsync. iOS returns a
314
+ // security-scoped URL: readable only between startAccessingSecurityScopedResource
315
+ // and its counterpart, and not readable at all after a relaunch without a
316
+ // bookmark. So the shell copies the file into the app's container while the
317
+ // scope is held and hands back that path, which readFileAsync can already
318
+ // open. The copy is a snapshot; nothing tracks the original afterwards.
319
+
320
+ std::string js_quote(const std::string &raw); // defined with the event channel
321
+
322
+ namespace objc = webview::detail::objc;
323
+
324
+ id ns_string(const std::string &s) {
325
+ return objc::msg_send<id>(objc::get_class("NSString"),
326
+ objc::selector("stringWithUTF8String:"), s.c_str());
327
+ }
328
+
329
+ std::string from_ns_string(id s) {
330
+ if (!s) {
331
+ return {};
332
+ }
333
+ const char *c = objc::msg_send<const char *>(s, objc::selector("UTF8String"));
334
+ return c ? std::string(c) : std::string{};
335
+ }
336
+
337
+ /// Extension -> uniform type identifier.
338
+ ///
339
+ /// UIDocumentPickerViewController wants UTIs, and turning an arbitrary
340
+ /// extension into one needs UniformTypeIdentifiers or CoreServices. Rather
341
+ /// than link a framework for a lookup table, this covers the common cases; an
342
+ /// extension that is not here widens the picker to public.item and says so in
343
+ /// the log, so a filter is never silently dropped.
344
+ const char *uti_for_extension(const std::string &ext) {
345
+ static const std::map<std::string, const char *> kUtis = {
346
+ {"txt", "public.plain-text"}, {"text", "public.plain-text"},
347
+ {"md", "net.daringfireball.markdown"},
348
+ {"json", "public.json"}, {"csv", "public.comma-separated-values-text"},
349
+ {"xml", "public.xml"}, {"html", "public.html"},
350
+ {"htm", "public.html"}, {"pdf", "com.adobe.pdf"},
351
+ {"png", "public.png"}, {"jpg", "public.jpeg"},
352
+ {"jpeg", "public.jpeg"}, {"gif", "com.compuserve.gif"},
353
+ {"heic", "public.heic"}, {"mp3", "public.mp3"},
354
+ {"wav", "com.microsoft.waveform-audio"},
355
+ {"mp4", "public.mpeg-4"}, {"mov", "com.apple.quicktime-movie"},
356
+ {"zip", "public.zip-archive"}, {"js", "com.netscape.javascript-source"},
357
+ {"ts", "public.plain-text"}, {"css", "public.css"},
358
+ {"yaml", "public.yaml"}, {"yml", "public.yaml"},
359
+ };
360
+ auto it = kUtis.find(ext);
361
+ return it == kUtis.end() ? nullptr : it->second;
362
+ }
363
+
364
+ std::string lower(std::string s) {
365
+ for (char &c : s) {
366
+ if (c >= 'A' && c <= 'Z') {
367
+ c = (char)(c - 'A' + 'a');
368
+ }
369
+ }
370
+ return s;
371
+ }
372
+
373
+ /// Copy a picked file into the app's container, returning the new path.
374
+ /// Called with the security scope already held.
375
+ std::string copy_into_container(const std::string &src) {
376
+ std::string name = src;
377
+ size_t slash = name.find_last_of('/');
378
+ if (slash != std::string::npos) {
379
+ name = name.substr(slash + 1);
380
+ }
381
+ if (name.empty()) {
382
+ name = "picked";
383
+ }
384
+ std::string dest = resolve_path("picked/" + name);
385
+ size_t cut = dest.find_last_of('/');
386
+ if (cut != std::string::npos) {
387
+ // The pick directory may not exist yet; mkdir -p one level is enough.
388
+ std::string dir = dest.substr(0, cut);
389
+ ::mkdir(dir.c_str(), 0755);
390
+ }
391
+ std::ifstream in(src, std::ios::binary);
392
+ if (!in) {
393
+ return {};
394
+ }
395
+ std::ofstream out(dest, std::ios::binary | std::ios::trunc);
396
+ if (!out) {
397
+ return {};
398
+ }
399
+ out << in.rdbuf();
400
+ out.close();
401
+ return out ? dest : std::string{};
402
+ }
403
+
404
+ /// The dialog job the picker delegate answers. Only ever touched on the main
405
+ /// queue, where UIKit presents and dismisses.
406
+ double g_dialog_id = 0;
407
+ bool g_dialog_open = false;
408
+
409
+ void finish_dialog(double id, bool ok, std::string payload) {
410
+ // BY VALUE, and dispatched: never re-enter the library from inside a UIKit
411
+ // callback (upstream #263) — this lands at the top of a later turn.
412
+ dispatch_async(dispatch_get_main_queue(), ^{
413
+ g_dialog_open = false;
414
+ jl_on_dialog_done(id, ok, payload.c_str(), payload.size());
415
+ });
416
+ }
417
+
418
+ /// Turn the picked URLs into a JSON array of container paths.
419
+ void deliver_picked(id urls) {
420
+ double id_ = g_dialog_id;
421
+ if (!urls) {
422
+ finish_dialog(id_, true, "[]");
423
+ return;
424
+ }
425
+ auto count = objc::msg_send<unsigned long>(urls, objc::selector("count"));
426
+ std::string json = "[";
427
+ for (unsigned long i = 0; i < count; i++) {
428
+ id url = objc::msg_send<id>(urls, objc::selector("objectAtIndex:"), i);
429
+ if (!url) {
430
+ continue;
431
+ }
432
+ // The scope must be held across the copy, and released even on failure.
433
+ bool scoped = objc::msg_send<bool>(
434
+ url, objc::selector("startAccessingSecurityScopedResource"));
435
+ std::string path =
436
+ from_ns_string(objc::msg_send<id>(url, objc::selector("path")));
437
+ std::string copied = copy_into_container(path);
438
+ if (scoped) {
439
+ objc::msg_send<void>(url,
440
+ objc::selector("stopAccessingSecurityScopedResource"));
441
+ }
442
+ if (copied.empty()) {
443
+ finish_dialog(id_, false,
444
+ "EIO: could not copy the picked file into the app's "
445
+ "container ('" + path + "')");
446
+ return;
447
+ }
448
+ if (json.size() > 1) {
449
+ json += ",";
450
+ }
451
+ json += js_quote(copied);
452
+ }
453
+ json += "]";
454
+ finish_dialog(id_, true, json);
455
+ }
456
+
457
+ /// The picker's delegate, built at runtime.
458
+ ///
459
+ /// UIKit needs a real Objective-C class to send the delegate messages to, and
460
+ /// this shell is plain C++, so the class is registered once with the runtime
461
+ /// and its two methods are plain C functions. One shared instance is enough:
462
+ /// only one picker can be up at a time, which g_dialog_open enforces.
463
+ Class picker_delegate_class() {
464
+ static Class cls = nullptr;
465
+ if (cls) {
466
+ return cls;
467
+ }
468
+ cls = objc_allocateClassPair(objc::get_class("NSObject"),
469
+ "JanelaPickerDelegate", 0);
470
+ class_addMethod(
471
+ cls, objc::selector("documentPicker:didPickDocumentsAtURLs:"),
472
+ (IMP)(+[](id, SEL, id, id urls) { deliver_picked(urls); }), "v@:@@");
473
+ class_addMethod(cls, objc::selector("documentPickerWasCancelled:"),
474
+ (IMP)(+[](id, SEL, id) {
475
+ // Cancel is not an error: an empty list becomes null.
476
+ finish_dialog(g_dialog_id, true, "[]");
477
+ }),
478
+ "v@:@");
479
+ objc_registerClassPair(cls);
480
+ return cls;
481
+ }
482
+
483
+ id picker_delegate() {
484
+ static id instance = objc::msg_send<id>(
485
+ objc::msg_send<id>((id)picker_delegate_class(), objc::selector("alloc")),
486
+ objc::selector("init"));
487
+ return instance;
488
+ }
489
+
490
+ /// TS -> shell: present a document picker. Records the request and returns;
491
+ /// UIKit work happens on the main queue, never inside this handler.
492
+ void host_open_dialog(void *, double job, const char *options, size_t len) {
493
+ std::string opts(options, len);
494
+ dispatch_async(dispatch_get_main_queue(), ^{
495
+ if (g_dialog_open) {
496
+ finish_dialog(job, false,
497
+ "EBUSY: a file dialog is already open on this app");
498
+ return;
499
+ }
500
+
501
+ // `directory` has no picker on iOS; report rather than quietly opening a
502
+ // file picker instead, which is what desktop does for unsupported options.
503
+ if (webview::detail::json_parse(opts, "directory", 0) == "true") {
504
+ finish_dialog(job, false,
505
+ "ENOTSUP: picking a directory is not supported on iOS");
506
+ return;
507
+ }
508
+
509
+ // Build the UTI list from the filters' extensions. An unmapped extension
510
+ // widens to public.item and is logged, so a filter is never silently lost.
511
+ id types = objc::msg_send<id>(objc::get_class("NSMutableArray"),
512
+ objc::selector("array"));
513
+ bool widened = false;
514
+ for (size_t i = 0;; i++) {
515
+ std::string ext = webview::detail::json_parse(opts, "extensions", i);
516
+ if (ext.empty()) {
517
+ break;
518
+ }
519
+ const char *uti = uti_for_extension(lower(ext));
520
+ if (uti) {
521
+ objc::msg_send<void>(types, objc::selector("addObject:"),
522
+ ns_string(uti));
523
+ } else if (!widened) {
524
+ widened = true;
525
+ std::fprintf(stderr,
526
+ "[janela] no uniform type identifier is mapped for '.%s', "
527
+ "so the picker is not filtered\n",
528
+ ext.c_str());
529
+ }
530
+ }
531
+ auto count = objc::msg_send<unsigned long>(types, objc::selector("count"));
532
+ if (count == 0 || widened) {
533
+ objc::msg_send<void>(types, objc::selector("addObject:"),
534
+ ns_string("public.item"));
535
+ }
536
+
537
+ // initWithDocumentTypes:inMode: rather than the UTType-based initialiser,
538
+ // which would mean linking UniformTypeIdentifiers for a lookup table.
539
+ id picker = objc::msg_send<id>(
540
+ objc::msg_send<id>(
541
+ objc::get_class("UIDocumentPickerViewController"),
542
+ objc::selector("alloc")),
543
+ objc::selector("initWithDocumentTypes:inMode:"), types,
544
+ (unsigned long)0 /* UIDocumentPickerModeImport: hands us a copy */);
545
+ if (!picker) {
546
+ finish_dialog(job, false, "EIO: could not create a document picker");
547
+ return;
548
+ }
549
+ objc::msg_send<void>(picker, objc::selector("setDelegate:"),
550
+ picker_delegate());
551
+ if (webview::detail::json_parse(opts, "multiple", 0) == "true") {
552
+ objc::msg_send<void>(picker, objc::selector("setAllowsMultipleSelection:"),
553
+ true);
554
+ }
555
+
556
+ id window = nullptr;
557
+ if (g_webview) {
558
+ auto w = g_webview->window();
559
+ if (w.ok()) {
560
+ window = (id)w.value();
561
+ }
562
+ }
563
+ id root = window ? objc::msg_send<id>(
564
+ window, objc::selector("rootViewController"))
565
+ : nullptr;
566
+ if (!root) {
567
+ finish_dialog(job, false,
568
+ "EINVAL: the app has no root view controller to present on");
569
+ return;
570
+ }
571
+
572
+ g_dialog_id = job;
573
+ g_dialog_open = true;
574
+ objc::msg_send<void>(root,
575
+ objc::selector("presentViewController:animated:completion:"),
576
+ picker, true, nullptr);
577
+ });
578
+ }
579
+
308
580
  // ---- host -> page: the event channel ---------------------------------------
309
581
 
310
582
  /// Minimal JSON string quoting, for splicing an event name into a JS call.
@@ -424,6 +696,8 @@ int main() {
424
696
  jl_set_callback("hostReadFile", (void (*)(void))host_read_file, nullptr) ||
425
697
  jl_set_callback("hostWriteFile", (void (*)(void))host_write_file,
426
698
  nullptr) ||
699
+ jl_set_callback("hostOpenDialog", (void (*)(void))host_open_dialog,
700
+ nullptr) ||
427
701
  jl_set_callback("janelaEmit", (void (*)(void))emit_event, nullptr)) {
428
702
  std::fprintf(stderr, "[janela] could not register a host channel\n");
429
703
  }