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/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 wvOnTick(h: number, cb: () => void): number;
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 wvTickStart(h: number, intervalMs: number): number;
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
- // 8 ms is plenty for timers and task chains, but while a payload is draining
152
- // the loop does real work every turn, and waiting 8 ms between 4 ms slices
153
- // would halve throughput for no benefit.
154
- const TICK_IDLE_MS = 8;
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
- // ---- the host loop -------------------------------------------------------
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
- // These queues are drained instead by the retained tick handler that the
178
- // shim's ticker posts to the UI thread, and the ticker only runs while there
179
- // is work - an idle app costs nothing.
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
- taskFns: (() => void)[] = [];
183
- timerFns: (() => void)[] = [];
184
- timerDue: number[] = [];
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
- retick(): void {
212
- const want = this.drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
213
- if (!this.ticking || want === this.tickMs) return;
214
- this.tickMs = want;
215
- wvTickStart(this.handle, want);
216
- }
217
-
218
- wake(): void {
219
- if (this.ticking) return;
220
- this.ticking = true;
221
- this.tickMs = this.drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
222
- wvTickStart(this.handle, this.tickMs);
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
- idle(): void {
226
- if (!this.ticking) return;
227
- if (
228
- this.taskFns.length > 0 ||
229
- this.timerFns.length > 0 ||
230
- this.jobIds.length > 0 ||
231
- this.drainIds.length > 0
232
- ) {
233
- return;
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) return;
297
+ if (Date.now() - started >= DRAIN_BUDGET_MS) {
298
+ this.drainMore();
299
+ return;
300
+ }
285
301
  }
286
302
  }
287
303
 
288
- // One turn of the loop: every task queued so far, plus every due timer.
289
- // Tasks queued *by* this turn wait for the next one, so a defer() chain
290
- // yields to the UI between slices instead of starving it.
291
- turn(): void {
292
- const tasks = this.taskFns;
293
- this.taskFns = [];
294
- for (let i = 0; i < tasks.length; i++) tasks[i]();
295
-
296
- if (this.timerFns.length > 0) {
297
- const now = Date.now() + 0;
298
- const keptFns: (() => void)[] = [];
299
- const keptDue: number[] = [];
300
- const fire: (() => void)[] = [];
301
- for (let i = 0; i < this.timerFns.length; i++) {
302
- if (this.timerDue[i] <= now) {
303
- fire.push(this.timerFns[i]);
304
- } else {
305
- keptFns.push(this.timerFns[i]);
306
- keptDue.push(this.timerDue[i]);
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
- // Finished file jobs: the worker thread has already done the blocking
315
- // syscall, so all that happens on this (UI) thread is the drain.
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.taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
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
- this.taskFns.push(fn);
431
- this.wake();
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 host loop's timer; scriptc's setTimeout
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.timerFns.push(fn);
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
- wvOnTick(h, () => {
535
- this.turn();
540
+ wvOnTimer(h, (id) => {
541
+ this.onTimer(id);
536
542
  });
537
543
  wvOnInvoke(h, (req) => {
538
544
  const env = JSON.parse(req) as string[];
@@ -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
+ }