janela 0.8.0 → 0.10.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 +23 -4
- package/bin/janela.mjs +266 -18
- package/package.json +4 -3
- package/runtime/ios.ts +483 -0
- package/runtime/janela.ts +88 -82
- package/shim/ios/app.mm +386 -0
- package/shim/wvshim.cc +166 -59
- package/templates/janela.conf.json +5 -0
- package/templates/react/files/janela.conf.json +5 -0
- package/templates/solid/files/janela.conf.json +5 -0
- package/templates/svelte/files/janela.conf.json +5 -0
- package/templates/vue/files/janela.conf.json +5 -0
package/runtime/janela.ts
CHANGED
|
@@ -19,13 +19,12 @@ declare function wvEval(h: number, js: string): number;
|
|
|
19
19
|
declare function wvBind(h: number, name: string): number;
|
|
20
20
|
declare function wvReply(h: number, body: string): number;
|
|
21
21
|
declare function wvOnInvoke(h: number, cb: (req: string) => number): number;
|
|
22
|
-
declare function
|
|
22
|
+
declare function wvOnTimer(h: number, cb: (id: number) => void): number;
|
|
23
23
|
declare function wvRun(h: number): number;
|
|
24
24
|
declare function wvTerminate(h: number): number;
|
|
25
25
|
declare function wvDefer(h: number): number;
|
|
26
26
|
declare function wvResolve(h: number, id: number, status: number): number;
|
|
27
|
-
declare function
|
|
28
|
-
declare function wvTickStop(h: number): number;
|
|
27
|
+
declare function wvSchedule(h: number, id: number, ms: number): number;
|
|
29
28
|
declare function wvFsRead(h: number, path: string): number;
|
|
30
29
|
declare function wvFsWrite(h: number, path: string, data: string): number;
|
|
31
30
|
declare function wvJobStatus(h: number, id: number): number;
|
|
@@ -148,11 +147,10 @@ function encodeFilters(filters: DialogFilter[] | undefined): string {
|
|
|
148
147
|
const DRAIN_BUDGET_MS = 4; // = a quarter of a 60fps frame
|
|
149
148
|
const DRAIN_SLICE = 131072; // 128 KB - granularity within the budget
|
|
150
149
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
const
|
|
155
|
-
const TICK_DRAIN_MS = 4;
|
|
150
|
+
// The shell posts this id when a file read or a dialog reaches a terminal
|
|
151
|
+
// state. It is not a continuation - it means "service the jobs you are
|
|
152
|
+
// waiting on". Continuation ids start at 1, so the two can never collide.
|
|
153
|
+
const TIMER_JOBS = -1;
|
|
156
154
|
|
|
157
155
|
/**
|
|
158
156
|
* A running janela app.
|
|
@@ -171,21 +169,23 @@ export class JanelaAppImpl<
|
|
|
171
169
|
names: string[] = [];
|
|
172
170
|
handlers: CommandHandler[] = [];
|
|
173
171
|
|
|
174
|
-
// ----
|
|
172
|
+
// ---- scheduling ----------------------------------------------------------
|
|
175
173
|
// scriptc's event loop is parked for as long as the program sits inside the
|
|
176
174
|
// wvRun() FFI call, so setTimeout/await never fire while the window is open.
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
175
|
+
// The shell schedules instead: a continuation is parked here under an id and
|
|
176
|
+
// handed to wvSchedule(), and the shim calls onTimer(id) back on the UI
|
|
177
|
+
// thread once it comes due. Nothing here polls and nothing wakes
|
|
178
|
+
// periodically - an idle app is genuinely idle.
|
|
179
|
+
//
|
|
180
|
+
// This is the same shape the iOS shell must use, where the compiled TS links
|
|
181
|
+
// no event loop at all and could not hold a timer even if it wanted to.
|
|
180
182
|
asyncNames: string[] = [];
|
|
181
183
|
asyncHandlers: AsyncCommandHandler[] = [];
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
contIds: number[] = [];
|
|
185
|
+
contFns: (() => void)[] = [];
|
|
186
|
+
nextCont = 1; // ids start at 1; TIMER_JOBS (-1) is the shell's own
|
|
185
187
|
jobIds: number[] = [];
|
|
186
188
|
jobCbs: FsCallback[] = [];
|
|
187
|
-
ticking = false;
|
|
188
|
-
tickMs = TICK_IDLE_MS;
|
|
189
189
|
|
|
190
190
|
// ---- the drain -----------------------------------------------------------
|
|
191
191
|
// A finished job's bytes still have to be decoded into a TypeScript string,
|
|
@@ -194,6 +194,7 @@ export class JanelaAppImpl<
|
|
|
194
194
|
// decoded a slice at a time, giving the run loop the thread back between
|
|
195
195
|
// slices - total work is unchanged, but no single turn carries much of it.
|
|
196
196
|
drainIds: number[] = [];
|
|
197
|
+
draining = false; // a drain continuation is already queued
|
|
197
198
|
drainCbs: FsCallback[] = [];
|
|
198
199
|
drainOk: boolean[] = [];
|
|
199
200
|
drainParts: string[][] = [];
|
|
@@ -208,37 +209,49 @@ export class JanelaAppImpl<
|
|
|
208
209
|
wvInit(h, BOOTSTRAP);
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
this.
|
|
221
|
-
this.
|
|
222
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Park `fn` with the shell and ask to be called back in `ms`.
|
|
214
|
+
*
|
|
215
|
+
* The id is the whole protocol: TS keeps the closure, the shell keeps the
|
|
216
|
+
* clock, and neither needs to know anything else about the other.
|
|
217
|
+
*/
|
|
218
|
+
schedule(ms: number, fn: () => void): void {
|
|
219
|
+
const id = this.nextCont;
|
|
220
|
+
this.nextCont = id + 1;
|
|
221
|
+
this.contIds.push(id);
|
|
222
|
+
this.contFns.push(fn);
|
|
223
|
+
const delay = ms > 0 ? ms : 0;
|
|
224
|
+
// `+ 0` per the note at the top of this file: a bare FFI call is not safe
|
|
225
|
+
// in every position, and this one is silently dropped without it.
|
|
226
|
+
const rc = wvSchedule(this.handle, id, delay) + 0;
|
|
227
|
+
if (rc < 0) console.log("[janela] could not schedule continuation", id);
|
|
223
228
|
}
|
|
224
229
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
this.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
230
|
+
/** Run the continuation parked under `id`, if it is still waiting. */
|
|
231
|
+
runCont(id: number): void {
|
|
232
|
+
for (let i = 0; i < this.contIds.length; i++) {
|
|
233
|
+
if (this.contIds[i] === id) {
|
|
234
|
+
const fn = this.contFns[i];
|
|
235
|
+
// Unregister BEFORE running: a continuation that schedules another one
|
|
236
|
+
// must not disturb the entry being removed, and a continuation that
|
|
237
|
+
// throws must not stay parked forever.
|
|
238
|
+
this.contIds.splice(i, 1);
|
|
239
|
+
this.contFns.splice(i, 1);
|
|
240
|
+
fn();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
234
243
|
}
|
|
235
|
-
this.ticking = false;
|
|
236
|
-
wvTickStop(this.handle);
|
|
237
244
|
}
|
|
238
245
|
|
|
239
246
|
// Decode as much of the pending payloads as the budget allows, then yield.
|
|
240
247
|
// Slices are taken from one job at a time so a big read finishes promptly
|
|
241
248
|
// rather than every concurrent read finishing slowly.
|
|
249
|
+
/**
|
|
250
|
+
* Decode as much of the pending payloads as the budget allows, then hand the
|
|
251
|
+
* thread back. If work remains, a zero-delay continuation carries on at the
|
|
252
|
+
* top of the next turn, so a 100 MB read is spread across frames instead of
|
|
253
|
+
* freezing one.
|
|
254
|
+
*/
|
|
242
255
|
drainSome(): void {
|
|
243
256
|
if (this.drainIds.length === 0) return;
|
|
244
257
|
const started = Date.now() + 0;
|
|
@@ -281,38 +294,39 @@ export class JanelaAppImpl<
|
|
|
281
294
|
// before starting another payload.
|
|
282
295
|
}
|
|
283
296
|
|
|
284
|
-
if (Date.now() - started >= DRAIN_BUDGET_MS)
|
|
297
|
+
if (Date.now() - started >= DRAIN_BUDGET_MS) {
|
|
298
|
+
this.drainMore();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
285
301
|
}
|
|
286
302
|
}
|
|
287
303
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
this.timerFns = keptFns;
|
|
310
|
-
this.timerDue = keptDue;
|
|
311
|
-
for (let i = 0; i < fire.length; i++) fire[i]();
|
|
304
|
+
/** Continue draining on the next turn, without stacking duplicate work. */
|
|
305
|
+
drainMore(): void {
|
|
306
|
+
if (this.drainIds.length === 0 || this.draining) return;
|
|
307
|
+
this.draining = true;
|
|
308
|
+
this.schedule(0, () => {
|
|
309
|
+
this.draining = false;
|
|
310
|
+
this.drainSome();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The shell calls here on the UI thread when something it was holding comes
|
|
316
|
+
* due: a continuation TS parked (id >= 1), or a job that finished
|
|
317
|
+
* (TIMER_JOBS). This is the only entry into the loop, and it always lands at
|
|
318
|
+
* the top of a fresh turn — never underneath a TS frame.
|
|
319
|
+
*/
|
|
320
|
+
onTimer(id: number): void {
|
|
321
|
+
if (id !== TIMER_JOBS) {
|
|
322
|
+
this.runCont(id);
|
|
323
|
+
return;
|
|
312
324
|
}
|
|
325
|
+
this.serviceJobs();
|
|
326
|
+
}
|
|
313
327
|
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
/** A job reached a terminal state: move the finished ones to the drain. */
|
|
329
|
+
serviceJobs(): void {
|
|
316
330
|
if (this.jobIds.length > 0) {
|
|
317
331
|
const keptIds: number[] = [];
|
|
318
332
|
const keptCbs: FsCallback[] = [];
|
|
@@ -346,8 +360,6 @@ export class JanelaAppImpl<
|
|
|
346
360
|
}
|
|
347
361
|
|
|
348
362
|
this.drainSome();
|
|
349
|
-
this.retick();
|
|
350
|
-
this.idle();
|
|
351
363
|
}
|
|
352
364
|
|
|
353
365
|
// Both dialog kinds share one path: start the job, then let the same drain
|
|
@@ -371,8 +383,7 @@ export class JanelaAppImpl<
|
|
|
371
383
|
encodeFilters(filters),
|
|
372
384
|
) + 0;
|
|
373
385
|
if (id < 0) {
|
|
374
|
-
this.
|
|
375
|
-
this.wake();
|
|
386
|
+
this.defer(() => cb(null, "EAGAIN: could not open a dialog"));
|
|
376
387
|
return;
|
|
377
388
|
}
|
|
378
389
|
this.jobIds.push(id);
|
|
@@ -384,7 +395,6 @@ export class JanelaAppImpl<
|
|
|
384
395
|
// "null" is a cancel; anything else is a JSON array of paths.
|
|
385
396
|
cb(JSON.parse(text) as string[] | null);
|
|
386
397
|
});
|
|
387
|
-
this.wake();
|
|
388
398
|
}
|
|
389
399
|
|
|
390
400
|
/**
|
|
@@ -427,16 +437,14 @@ export class JanelaAppImpl<
|
|
|
427
437
|
|
|
428
438
|
/** Run fn on the next turn of the host loop - the way to slice long work. */
|
|
429
439
|
defer(fn: () => void): void {
|
|
430
|
-
|
|
431
|
-
this.
|
|
440
|
+
// Zero delay: the shell posts straight to the next turn, no timer at all.
|
|
441
|
+
this.schedule(0, fn);
|
|
432
442
|
}
|
|
433
443
|
|
|
434
|
-
/** Run fn after at least ms. The
|
|
444
|
+
/** Run fn after at least ms. The shell owns the clock; scriptc's setTimeout
|
|
435
445
|
* cannot fire while the window is open (its loop is parked inside run()). */
|
|
436
446
|
sleep(ms: number, fn: () => void): void {
|
|
437
|
-
this.
|
|
438
|
-
this.timerDue.push(Date.now() + (ms > 0 ? ms : 0));
|
|
439
|
-
this.wake();
|
|
447
|
+
this.schedule(ms, fn);
|
|
440
448
|
}
|
|
441
449
|
|
|
442
450
|
/**
|
|
@@ -451,7 +459,6 @@ export class JanelaAppImpl<
|
|
|
451
459
|
}
|
|
452
460
|
this.jobIds.push(id);
|
|
453
461
|
this.jobCbs.push(cb);
|
|
454
|
-
this.wake();
|
|
455
462
|
}
|
|
456
463
|
|
|
457
464
|
/** Write a file without blocking the window; cb(null) on success. */
|
|
@@ -465,7 +472,6 @@ export class JanelaAppImpl<
|
|
|
465
472
|
// The write payload is empty on success; the shared callback shape just
|
|
466
473
|
// ignores the text argument.
|
|
467
474
|
this.jobCbs.push((err, _text) => cb(err));
|
|
468
|
-
this.wake();
|
|
469
475
|
}
|
|
470
476
|
|
|
471
477
|
/** Show the native "open" dialog; cb gets the paths, or null on cancel. */
|
|
@@ -531,8 +537,8 @@ export class JanelaAppImpl<
|
|
|
531
537
|
const h = this.handle;
|
|
532
538
|
// Both handlers are retained: registered once here, called by the shim
|
|
533
539
|
// for as long as the window is open.
|
|
534
|
-
|
|
535
|
-
this.
|
|
540
|
+
wvOnTimer(h, (id) => {
|
|
541
|
+
this.onTimer(id);
|
|
536
542
|
});
|
|
537
543
|
wvOnInvoke(h, (req) => {
|
|
538
544
|
const env = JSON.parse(req) as string[];
|
package/shim/ios/app.mm
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// janela's iOS shell: UIKit owns the run loop, WKWebView is the window, and
|
|
2
|
+
// the app's TypeScript is a linked scriptc library we call into.
|
|
3
|
+
//
|
|
4
|
+
// This is the mirror image of the desktop shim. There, TypeScript owns main()
|
|
5
|
+
// and drives a C library over FFI; here the platform owns main() and calls a
|
|
6
|
+
// TypeScript library. scriptc builds iOS as a library only, and library mode
|
|
7
|
+
// links no event loop (SC4005), so there is nothing to pump: each invoke runs
|
|
8
|
+
// to completion and returns.
|
|
9
|
+
//
|
|
10
|
+
// The WKWebView / WKUserContentController / script-message-handler wiring
|
|
11
|
+
// follows the approach used by wry (https://github.com/tauri-apps/wry,
|
|
12
|
+
// Apache-2.0, © Tauri Programme within The Commons Conservancy) in
|
|
13
|
+
// src/wkwebview/. wry attaches its webview to a UIView supplied by tao; janela
|
|
14
|
+
// has no tao, so it creates the UIWindow and root view controller itself. The
|
|
15
|
+
// IPC envelope and the page-side bridge are janela's own, and match what the
|
|
16
|
+
// desktop shim injects so that `janela/api` works unchanged.
|
|
17
|
+
|
|
18
|
+
#import <UIKit/UIKit.h>
|
|
19
|
+
#import <WebKit/WebKit.h>
|
|
20
|
+
|
|
21
|
+
#include <string.h>
|
|
22
|
+
|
|
23
|
+
// ---- the scriptc library's C ABI (see the generated profile) ---------------
|
|
24
|
+
extern "C" {
|
|
25
|
+
void jl_init(void);
|
|
26
|
+
void jl_reset(void);
|
|
27
|
+
void jl_set_panic_sink(void (*fn)(void *, const char *, size_t, const char *, size_t), void *ctx);
|
|
28
|
+
int32_t jl_set_callback(const char *name, void (*fn)(void), void *ctx);
|
|
29
|
+
void jl_handle_invoke(const char *cmd, size_t cmd_len, const char *args, size_t args_len,
|
|
30
|
+
char **out, size_t *out_len);
|
|
31
|
+
void jl_index_html(char **out, size_t *out_len);
|
|
32
|
+
void jl_on_timer(double id);
|
|
33
|
+
void jl_on_fs_done(double id, bool ok, const char *payload, size_t payload_len);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static WKWebView *gWebView = nil;
|
|
37
|
+
|
|
38
|
+
/// Settle one page-side promise with an envelope that is already JSON.
|
|
39
|
+
static void settlePage(NSNumber *callId, NSString *envelope) {
|
|
40
|
+
NSString *js =
|
|
41
|
+
[NSString stringWithFormat:@"window.__janelaSettle(%@,%@);", callId, envelope];
|
|
42
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
43
|
+
[gWebView evaluateJavaScript:js completionHandler:nil];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static void panicSink(void *ctx, const char *sym, size_t symlen, const char *msg, size_t msglen) {
|
|
48
|
+
(void)ctx;
|
|
49
|
+
NSLog(@"[janela] host panic in %.*s: %.*s", (int)symlen, sym, (int)msglen, msg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- what the shell owns for the library ----------------------------------
|
|
53
|
+
//
|
|
54
|
+
// Two tables, mirroring the desktop shim: continuations the library parked
|
|
55
|
+
// with us, and page replies we are holding until the library says what they
|
|
56
|
+
// are. Both live on the main queue and are only touched there.
|
|
57
|
+
//
|
|
58
|
+
// THE RULE (upstream #263): a channel handler must never re-enter the library.
|
|
59
|
+
// Every handler below therefore only records what it was told and returns; the
|
|
60
|
+
// re-entry happens from a dispatch_async block, i.e. at the top of a later
|
|
61
|
+
// turn with no library frame beneath it. A breach silently appears to work, so
|
|
62
|
+
// this has to hold by construction rather than by testing.
|
|
63
|
+
|
|
64
|
+
/// Page replies whose answer was not ready when handle_invoke returned.
|
|
65
|
+
/// pendingId -> the page-side call id we have not settled yet.
|
|
66
|
+
static NSMutableDictionary<NSNumber *, NSNumber *> *gHeld = nil;
|
|
67
|
+
/// Envelopes that arrived before we knew the page call id — a command that
|
|
68
|
+
/// resolves synchronously settles during its own dispatch, before the
|
|
69
|
+
/// {"pending":id} envelope has made it back to us.
|
|
70
|
+
static NSMutableDictionary<NSNumber *, NSString *> *gEarly = nil;
|
|
71
|
+
|
|
72
|
+
/// Answer the page for a pending id, whichever half arrived first.
|
|
73
|
+
static void resolvePending(NSNumber *pendingId, NSString *envelope) {
|
|
74
|
+
NSNumber *callId = gHeld[pendingId];
|
|
75
|
+
if (callId) {
|
|
76
|
+
[gHeld removeObjectForKey:pendingId];
|
|
77
|
+
settlePage(callId, envelope);
|
|
78
|
+
} else {
|
|
79
|
+
gEarly[pendingId] = envelope; // handle_invoke has not returned yet
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Register the page call id a pending answer belongs to.
|
|
84
|
+
static void holdPending(NSNumber *pendingId, NSNumber *callId) {
|
|
85
|
+
NSString *early = gEarly[pendingId];
|
|
86
|
+
if (early) {
|
|
87
|
+
[gEarly removeObjectForKey:pendingId];
|
|
88
|
+
settlePage(callId, early); // it resolved synchronously; answer now
|
|
89
|
+
} else {
|
|
90
|
+
gHeld[pendingId] = callId;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- channels the library calls (TS -> shell) ------------------------------
|
|
95
|
+
|
|
96
|
+
/// Park a continuation with us and call back when it comes due. A zero delay
|
|
97
|
+
/// still goes through dispatch_after(0), which posts to the next turn — that
|
|
98
|
+
/// is app.defer(), and it costs no timer.
|
|
99
|
+
static void hostSchedule(void *ctx, double id, double ms) {
|
|
100
|
+
(void)ctx;
|
|
101
|
+
int64_t delay = (int64_t)(ms > 0 ? ms : 0);
|
|
102
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay * NSEC_PER_MSEC),
|
|
103
|
+
dispatch_get_main_queue(), ^{
|
|
104
|
+
jl_on_timer(id); // fresh turn: no library frame beneath us
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// The library has an answer for a held page reply.
|
|
109
|
+
static void hostSettle(void *ctx, double pendingId, const char *env, size_t env_len) {
|
|
110
|
+
(void)ctx;
|
|
111
|
+
NSString *envelope = [[NSString alloc] initWithBytes:env
|
|
112
|
+
length:env_len
|
|
113
|
+
encoding:NSUTF8StringEncoding] ?: @"null";
|
|
114
|
+
NSNumber *key = @((int64_t)pendingId);
|
|
115
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
116
|
+
resolvePending(key, envelope);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// The file queue: blocking I/O happens here, never on the main queue, and
|
|
121
|
+
/// never inside the library. The library links no threads of its own, so this
|
|
122
|
+
/// is the only place a read can go.
|
|
123
|
+
/// An iOS app has no useful working directory and cannot see host paths, so a
|
|
124
|
+
/// relative path is taken as relative to the app's Documents directory — the
|
|
125
|
+
/// one place a sandboxed app can freely read and write.
|
|
126
|
+
static NSString *resolvePath(NSString *path) {
|
|
127
|
+
if ([path hasPrefix:@"/"]) return path;
|
|
128
|
+
NSArray<NSString *> *dirs =
|
|
129
|
+
NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
|
|
130
|
+
NSString *docs = dirs.firstObject ?: NSTemporaryDirectory();
|
|
131
|
+
return [docs stringByAppendingPathComponent:path];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
static dispatch_queue_t fsQueue(void) {
|
|
135
|
+
static dispatch_queue_t q;
|
|
136
|
+
static dispatch_once_t once;
|
|
137
|
+
dispatch_once(&once, ^{
|
|
138
|
+
q = dispatch_queue_create("dev.janela.fs", DISPATCH_QUEUE_SERIAL);
|
|
139
|
+
});
|
|
140
|
+
return q;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
static void finishFs(double id, bool ok, NSString *payload) {
|
|
144
|
+
// Convert INSIDE the block: -UTF8String hands back an autoreleased buffer
|
|
145
|
+
// whose lifetime does not survive the hop to another queue. The NSString is
|
|
146
|
+
// captured (and retained) by the block; the bytes are taken on arrival.
|
|
147
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
148
|
+
const char *bytes = payload.UTF8String ?: "";
|
|
149
|
+
jl_on_fs_done(id, ok, bytes, strlen(bytes));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
static void hostReadFile(void *ctx, double id, const char *path, size_t path_len) {
|
|
154
|
+
(void)ctx;
|
|
155
|
+
NSString *p = resolvePath([[NSString alloc] initWithBytes:path
|
|
156
|
+
length:path_len
|
|
157
|
+
encoding:NSUTF8StringEncoding] ?: @"");
|
|
158
|
+
dispatch_async(fsQueue(), ^{
|
|
159
|
+
NSError *err = nil;
|
|
160
|
+
NSString *text = [NSString stringWithContentsOfFile:p
|
|
161
|
+
encoding:NSUTF8StringEncoding
|
|
162
|
+
error:&err];
|
|
163
|
+
if (text) finishFs(id, true, text);
|
|
164
|
+
else finishFs(id, false, err.localizedDescription ?: @"read failed");
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
static void hostWriteFile(void *ctx, double id, const char *path, size_t path_len,
|
|
169
|
+
const char *data, size_t data_len) {
|
|
170
|
+
(void)ctx;
|
|
171
|
+
NSString *p = resolvePath([[NSString alloc] initWithBytes:path
|
|
172
|
+
length:path_len
|
|
173
|
+
encoding:NSUTF8StringEncoding] ?: @"");
|
|
174
|
+
NSString *d = [[NSString alloc] initWithBytes:data
|
|
175
|
+
length:data_len
|
|
176
|
+
encoding:NSUTF8StringEncoding] ?: @"";
|
|
177
|
+
dispatch_async(fsQueue(), ^{
|
|
178
|
+
NSError *err = nil;
|
|
179
|
+
BOOL ok = [d writeToFile:p atomically:YES encoding:NSUTF8StringEncoding error:&err];
|
|
180
|
+
if (ok) finishFs(id, true, @"");
|
|
181
|
+
else finishFs(id, false, err.localizedDescription ?: @"write failed");
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Copy a library-owned result out of its arena and release it. Results live
|
|
186
|
+
/// until the next jl_reset(), so nothing may hold the pointer past this call.
|
|
187
|
+
static NSString *takeResult(char *out, size_t out_len) {
|
|
188
|
+
NSString *s = out ? [[NSString alloc] initWithBytes:out
|
|
189
|
+
length:out_len
|
|
190
|
+
encoding:NSUTF8StringEncoding]
|
|
191
|
+
: nil;
|
|
192
|
+
jl_reset();
|
|
193
|
+
return s ?: @"null";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Escape a JSON document for splicing into a JS expression as a string
|
|
197
|
+
/// literal. Used for the event payload, which the page parses back.
|
|
198
|
+
static NSString *jsQuote(NSString *raw) {
|
|
199
|
+
NSData *d = [NSJSONSerialization dataWithJSONObject:@[ raw ] options:0 error:nil];
|
|
200
|
+
NSString *arr = [[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding];
|
|
201
|
+
// ["..."] -> "..."
|
|
202
|
+
return [arr substringWithRange:NSMakeRange(1, arr.length - 2)];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- host -> page: the event channel ---------------------------------------
|
|
206
|
+
//
|
|
207
|
+
// The library calls this through the declared callback channel whenever the
|
|
208
|
+
// app emits. Evaluating JS must happen on the main queue.
|
|
209
|
+
|
|
210
|
+
static void emitEvent(void *ctx, const char *name, size_t name_len,
|
|
211
|
+
const char *payload, size_t payload_len) {
|
|
212
|
+
(void)ctx;
|
|
213
|
+
NSString *event = [[NSString alloc] initWithBytes:name length:name_len
|
|
214
|
+
encoding:NSUTF8StringEncoding];
|
|
215
|
+
NSString *json = [[NSString alloc] initWithBytes:payload length:payload_len
|
|
216
|
+
encoding:NSUTF8StringEncoding];
|
|
217
|
+
if (!event || !json) return;
|
|
218
|
+
NSString *js = [NSString stringWithFormat:@"window.__wvEmit(%@,%@);", jsQuote(event), json];
|
|
219
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
220
|
+
[gWebView evaluateJavaScript:js completionHandler:nil];
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- page -> host: the invoke bridge ---------------------------------------
|
|
225
|
+
|
|
226
|
+
@interface JanelaBridge : NSObject <WKScriptMessageHandler>
|
|
227
|
+
@end
|
|
228
|
+
|
|
229
|
+
@implementation JanelaBridge
|
|
230
|
+
|
|
231
|
+
- (void)userContentController:(WKUserContentController *)controller
|
|
232
|
+
didReceiveScriptMessage:(WKScriptMessage *)message {
|
|
233
|
+
NSDictionary *body = (NSDictionary *)message.body;
|
|
234
|
+
if (![body isKindOfClass:NSDictionary.class]) return;
|
|
235
|
+
|
|
236
|
+
NSNumber *callId = body[@"id"];
|
|
237
|
+
NSString *cmd = body[@"cmd"];
|
|
238
|
+
NSString *args = body[@"args"] ?: @"null";
|
|
239
|
+
if (!callId || !cmd) return;
|
|
240
|
+
|
|
241
|
+
const char *c = cmd.UTF8String;
|
|
242
|
+
const char *a = args.UTF8String;
|
|
243
|
+
char *out = NULL;
|
|
244
|
+
size_t out_len = 0;
|
|
245
|
+
jl_handle_invoke(c, strlen(c), a, strlen(a), &out, &out_len);
|
|
246
|
+
NSString *reply = takeResult(out, out_len);
|
|
247
|
+
|
|
248
|
+
// An async command answers later: the library returns {"pending":<id>} and
|
|
249
|
+
// we hold the page's promise until hostSettle() tells us what it is.
|
|
250
|
+
NSData *replyData = [reply dataUsingEncoding:NSUTF8StringEncoding];
|
|
251
|
+
NSDictionary *env = replyData
|
|
252
|
+
? [NSJSONSerialization JSONObjectWithData:replyData options:0 error:nil]
|
|
253
|
+
: nil;
|
|
254
|
+
NSNumber *pendingId = [env isKindOfClass:NSDictionary.class] ? env[@"pending"] : nil;
|
|
255
|
+
if (pendingId) {
|
|
256
|
+
holdPending(pendingId, callId);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Otherwise the reply is an envelope: {"ok":true,"value":…} or
|
|
261
|
+
// {"ok":false,"error":…}, already JSON, so it splices straight into the
|
|
262
|
+
// expression that settles the page-side promise.
|
|
263
|
+
settlePage(callId, reply);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
@end
|
|
267
|
+
|
|
268
|
+
// ---- the page-side bridge --------------------------------------------------
|
|
269
|
+
//
|
|
270
|
+
// Same surface the desktop shim injects — window.janela.invoke/listen and
|
|
271
|
+
// window.__wvEmit — so `janela/api` and a project's frontend work unchanged.
|
|
272
|
+
// Only the transport differs: a script message instead of a webview bind.
|
|
273
|
+
|
|
274
|
+
static NSString *const kBootstrap =
|
|
275
|
+
@"window.__wvListeners = {};"
|
|
276
|
+
@"window.__janelaPending = {};"
|
|
277
|
+
@"window.__janelaSeq = 0;"
|
|
278
|
+
@"window.__janelaSettle = function (id, env) {"
|
|
279
|
+
@" var p = window.__janelaPending[id];"
|
|
280
|
+
@" if (!p) return;"
|
|
281
|
+
@" delete window.__janelaPending[id];"
|
|
282
|
+
@" if (env && env.ok) p.resolve(env.value); else p.reject(new Error((env && env.error) || 'janela: invoke failed'));"
|
|
283
|
+
@"};"
|
|
284
|
+
@"window.janela = {"
|
|
285
|
+
@" invoke: function (cmd, args) {"
|
|
286
|
+
@" var id = ++window.__janelaSeq;"
|
|
287
|
+
@" return new Promise(function (resolve, reject) {"
|
|
288
|
+
@" window.__janelaPending[id] = { resolve: resolve, reject: reject };"
|
|
289
|
+
@" window.webkit.messageHandlers.janela.postMessage({"
|
|
290
|
+
@" id: id, cmd: cmd, args: JSON.stringify(args === undefined ? null : args)"
|
|
291
|
+
@" });"
|
|
292
|
+
@" });"
|
|
293
|
+
@" },"
|
|
294
|
+
@" listen: function (event, cb) {"
|
|
295
|
+
@" if (!window.__wvListeners[event]) window.__wvListeners[event] = [];"
|
|
296
|
+
@" window.__wvListeners[event].push(cb);"
|
|
297
|
+
@" return function () {"
|
|
298
|
+
@" var a = window.__wvListeners[event] || [];"
|
|
299
|
+
@" var i = a.indexOf(cb);"
|
|
300
|
+
@" if (i >= 0) a.splice(i, 1);"
|
|
301
|
+
@" };"
|
|
302
|
+
@" },"
|
|
303
|
+
@"};"
|
|
304
|
+
@"window.__wvEmit = function (event, payload) {"
|
|
305
|
+
@" var cbs = window.__wvListeners[event] || [];"
|
|
306
|
+
@" for (var i = 0; i < cbs.length; i++) cbs[i](payload);"
|
|
307
|
+
@"};";
|
|
308
|
+
|
|
309
|
+
@interface JanelaViewController : UIViewController
|
|
310
|
+
@end
|
|
311
|
+
|
|
312
|
+
@implementation JanelaViewController {
|
|
313
|
+
JanelaBridge *_bridge;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
- (void)viewDidLoad {
|
|
317
|
+
[super viewDidLoad];
|
|
318
|
+
|
|
319
|
+
WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
|
|
320
|
+
WKUserContentController *manager = config.userContentController;
|
|
321
|
+
|
|
322
|
+
_bridge = [[JanelaBridge alloc] init];
|
|
323
|
+
[manager addScriptMessageHandler:_bridge name:@"janela"];
|
|
324
|
+
|
|
325
|
+
// Injected before the document loads, exactly as the desktop shim does with
|
|
326
|
+
// webview_init — so the page can call janela.invoke from its first line.
|
|
327
|
+
WKUserScript *boot = [[WKUserScript alloc] initWithSource:kBootstrap
|
|
328
|
+
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
|
|
329
|
+
forMainFrameOnly:YES];
|
|
330
|
+
[manager addUserScript:boot];
|
|
331
|
+
|
|
332
|
+
WKWebView *webview = [[WKWebView alloc] initWithFrame:self.view.bounds
|
|
333
|
+
configuration:config];
|
|
334
|
+
webview.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
|
335
|
+
[self.view addSubview:webview];
|
|
336
|
+
gWebView = webview;
|
|
337
|
+
|
|
338
|
+
char *out = NULL;
|
|
339
|
+
size_t out_len = 0;
|
|
340
|
+
jl_index_html(&out, &out_len);
|
|
341
|
+
NSString *html = takeResult(out, out_len);
|
|
342
|
+
[webview loadHTMLString:html baseURL:nil];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@end
|
|
346
|
+
|
|
347
|
+
@interface JanelaAppDelegate : UIResponder <UIApplicationDelegate>
|
|
348
|
+
@property(nonatomic, strong) UIWindow *window;
|
|
349
|
+
@end
|
|
350
|
+
|
|
351
|
+
@implementation JanelaAppDelegate
|
|
352
|
+
|
|
353
|
+
- (BOOL)application:(UIApplication *)application
|
|
354
|
+
didFinishLaunchingWithOptions:(NSDictionary *)options {
|
|
355
|
+
// Registration is a pure store and is legal before init; the panic sink and
|
|
356
|
+
// the event channel must both be in place before any TypeScript runs, since
|
|
357
|
+
// setup() executes during jl_init().
|
|
358
|
+
jl_set_panic_sink(panicSink, NULL);
|
|
359
|
+
gHeld = [NSMutableDictionary dictionary];
|
|
360
|
+
gEarly = [NSMutableDictionary dictionary];
|
|
361
|
+
// Registration is a pure store and is legal before init; it also survives
|
|
362
|
+
// jl_reset(), so it is done once here.
|
|
363
|
+
if (jl_set_callback("hostSchedule", (void (*)(void))hostSchedule, NULL) != 0 ||
|
|
364
|
+
jl_set_callback("hostSettle", (void (*)(void))hostSettle, NULL) != 0 ||
|
|
365
|
+
jl_set_callback("hostReadFile", (void (*)(void))hostReadFile, NULL) != 0 ||
|
|
366
|
+
jl_set_callback("hostWriteFile", (void (*)(void))hostWriteFile, NULL) != 0) {
|
|
367
|
+
NSLog(@"[janela] could not register a host channel");
|
|
368
|
+
}
|
|
369
|
+
if (jl_set_callback("janelaEmit", (void (*)(void))emitEvent, NULL) != 0) {
|
|
370
|
+
NSLog(@"[janela] could not register the event channel — app.emit will trap");
|
|
371
|
+
}
|
|
372
|
+
jl_init();
|
|
373
|
+
|
|
374
|
+
self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];
|
|
375
|
+
self.window.rootViewController = [[JanelaViewController alloc] init];
|
|
376
|
+
[self.window makeKeyAndVisible];
|
|
377
|
+
return YES;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
@end
|
|
381
|
+
|
|
382
|
+
int main(int argc, char *argv[]) {
|
|
383
|
+
@autoreleasepool {
|
|
384
|
+
return UIApplicationMain(argc, argv, nil, NSStringFromClass([JanelaAppDelegate class]));
|
|
385
|
+
}
|
|
386
|
+
}
|