janela 0.11.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/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
  }