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 +13 -3
- package/bin/janela.mjs +13 -0
- package/package.json +1 -1
- package/runtime/ios.ts +82 -8
- package/shim/android/app.cc +380 -0
- package/shim/android/java/dev/janela/host/JanelaActivity.java +144 -0
- package/shim/ios/app.cc +274 -0
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
|
|
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
|
|
47
|
-
|
|
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
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
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
382
|
+
options: OpenDialogOptions,
|
|
358
383
|
cb: (paths: string[] | null, err?: string) => void,
|
|
359
384
|
): void {
|
|
360
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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. */
|
package/shim/android/app.cc
CHANGED
|
@@ -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
|
}
|