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/ios.ts ADDED
@@ -0,0 +1,483 @@
1
+ // janela's iOS runtime lane.
2
+ //
3
+ // Same public surface as runtime/janela.ts — `app.command`, `app.emit`, the
4
+ // typed contract — so a project's src-host/main.ts compiles unchanged on both.
5
+ // The CLI picks a lane by copying one of these two files in as `./janela`.
6
+ //
7
+ // What differs is who is in charge. On desktop, TypeScript owns `main` and
8
+ // calls a blocking wvRun(); scriptc's event loop is parked for the app's life,
9
+ // so this runtime carries a ticker, a timer queue and a deferred-job pool to
10
+ // get work done anyway. On iOS none of that can exist:
11
+ //
12
+ // - scriptc builds iOS as a LIBRARY (it refuses executables for the target),
13
+ // and library mode requires an async-free module graph — SC4005 rejects a
14
+ // build whose graph reaches setTimeout, promises or threads. There is no
15
+ // event loop linked into the artifact at all.
16
+ // - UIKit owns the run loop and calls us. Each handleInvoke() runs to
17
+ // completion and returns, so nothing needs pumping.
18
+ //
19
+ // The result is much smaller: a command registry and a dispatch function.
20
+ // Everything that needed the loop is not available on iOS *yet* — it reports
21
+ // when called rather than failing silently, and every such path goes through
22
+ // one guard so that restoring parity is a single edit. See the stubs below.
23
+
24
+ import type {
25
+ AsyncCommandHandler,
26
+ CommandHandler,
27
+ CommandShapes,
28
+ CommandSpecs,
29
+ DialogFilter,
30
+ FsCallback,
31
+ Norm,
32
+ OpenDialogOptions,
33
+ SaveDialogOptions,
34
+ WindowConfig,
35
+ } from "./types";
36
+
37
+ // The host-callback channel declared in the generated library profile. The
38
+ // shell registers it before jl_init(); calling a channel the host never
39
+ // registered is a defined trap (SC4025), not undefined behaviour.
40
+ declare function janelaEmit(event: string, payloadJson: string): void;
41
+ declare function hostSchedule(id: number, ms: number): void;
42
+ declare function hostSettle(pendingId: number, envelopeJson: string): void;
43
+ declare function hostReadFile(jobId: number, path: string): void;
44
+ declare function hostWriteFile(jobId: number, path: string, data: string): void;
45
+
46
+ // One job table serves reads and writes; these sentinels say which callback of
47
+ // the pair is the real one. Identity comparison is the whole trick, so they
48
+ // must be module-level singletons rather than fresh closures.
49
+ const nullRead: FsCallback = (_e: string | null, _t: string) => {};
50
+ const nullWrite: (err: string | null) => void = (_e: string | null) => {};
51
+
52
+ function encode(value: unknown): string {
53
+ if (value === undefined) return "null";
54
+ return JSON.stringify(value);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // The one place iOS says "not yet"
59
+ // ---------------------------------------------------------------------------
60
+ //
61
+ // Everything below funnels through `pending()`. Nothing else in this file
62
+ // decides what is or is not available, so the day a capability lands on iOS
63
+ // its stub becomes a real implementation and this comment shrinks.
64
+ //
65
+ // The scheduling family — commandAsync, defer, sleep — and file I/O now work
66
+ // exactly as they do on desktop: TS never holds a timer or a file handle, it
67
+ // parks work with the shell under an id and is re-entered when the shell has
68
+ // an answer. A library build still links no event loop (SC4005); that is why
69
+ // the design routes through the shell rather than a limitation of iOS.
70
+ //
71
+ // What remains behind pending(): the file dialogs (iOS wants a document
72
+ // picker, which is its own delegate lifecycle) and window control, which is
73
+ // permanently meaningless on a phone rather than unfinished.
74
+ //
75
+ // FAILING LOUDLY WITHOUT KILLING THE APP: an uncaught throw in library mode
76
+ // reaches the panic sink and then ABORTS the process (SC4013). So a stub must
77
+ // never throw from setup() — registering an async command at startup would
78
+ // kill the app before its first frame. Stubs that hand back a callback report
79
+ // through it; fire-and-forget stubs log; and commandAsync registers a command
80
+ // that throws only when the page calls it, where dispatch()'s try/catch turns
81
+ // it into a rejected promise.
82
+
83
+ /** The single message every not-yet-on-iOS path reports. */
84
+ function pending(api: string, why: string): string {
85
+ return (
86
+ "janela: app." + api + " is not available on iOS yet — " + why + ". " +
87
+ "Parity is planned; see docs/ios.md."
88
+ );
89
+ }
90
+
91
+ /**
92
+ * The scheduling family's guard — commandAsync, defer and sleep.
93
+ *
94
+ * These three are absent for one shared reason, and will return for one
95
+ * shared reason. Replacing this function with a real implementation (timers
96
+ * scheduled by the shell, the library re-entered when they fire) is the whole
97
+ * of that change on this side.
98
+ */
99
+ /**
100
+ * A running janela app on iOS, typed by the contract it serves.
101
+ *
102
+ * Deliberately the same class name as the desktop lane: the generated entry
103
+ * and a project's main.ts are compiled against whichever file the CLI copied
104
+ * in, so both must present the same type.
105
+ */
106
+ export class JanelaAppImpl<
107
+ C extends CommandShapes = CommandShapes,
108
+ E = Record<string, unknown>,
109
+ > {
110
+ names: string[] = [];
111
+ handlers: CommandHandler[] = [];
112
+ html = "";
113
+
114
+ // ---- scheduling ----------------------------------------------------------
115
+ // Identical in shape to the desktop lane, and for a stronger reason: an iOS
116
+ // library links no event loop at all (SC4005), so TS could not hold a timer
117
+ // even if it wanted to. It parks a continuation under an id, asks the shell
118
+ // to schedule it, and the shell re-enters onTimer(id) on the main queue when
119
+ // it comes due. Nothing here polls.
120
+ contIds: number[] = [];
121
+ contFns: (() => void)[] = [];
122
+ nextCont = 1;
123
+
124
+ // An invoke whose answer is not ready when dispatch() returns. The shell
125
+ // holds the page's reply under this id and settles it when hostSettle()
126
+ // arrives. Mirrors the desktop shim's wv_defer/wv_resolve pair.
127
+ pendingIds: number[] = [];
128
+ nextPending = 1;
129
+ deferred = -1; // set by an async command during its own dispatch()
130
+
131
+ // In-flight file jobs: the shell does the blocking I/O on its own queue and
132
+ // re-enters onFsDone() with the result.
133
+ jobIds: number[] = [];
134
+ jobCbs: FsCallback[] = [];
135
+ jobWriteCbs: ((err: string | null) => void)[] = [];
136
+ nextJob = 1;
137
+
138
+ // Kept so the shared WindowConfig shape compiles; iOS has no window to size.
139
+ constructor(_cfg: WindowConfig) {}
140
+
141
+ /**
142
+ * Register a named command, callable from the page as janela.invoke(name, args).
143
+ *
144
+ * With a contract (`JanelaApp<App>`) the name must be one the contract
145
+ * declares, `args` is inferred from it, and the return value is checked
146
+ * against it. Without one, `args` is `unknown` and any name is accepted.
147
+ */
148
+ command<K extends keyof C & string>(
149
+ name: K,
150
+ handler: (args: C[K]["args"]) => C[K]["result"],
151
+ ): void {
152
+ this.names.push(name);
153
+ // The cast is on the VALUE, inside a contextually-typed closure: casting
154
+ // the function itself to another signature and calling through it fails
155
+ // at runtime.
156
+ this.handlers.push((args: unknown) => handler(args as C[K]["args"]));
157
+ }
158
+
159
+ /**
160
+ * Fire an event into the page; the payload is delivered as a value. Under a
161
+ * contract, the name must be declared and the payload must match its type.
162
+ *
163
+ * Reaches the page through the host-callback channel: the shell evaluates
164
+ * `window.__wvEmit(...)` on the main queue.
165
+ */
166
+ emit<K extends keyof E & string>(event: K, payload: E[K]): void {
167
+ janelaEmit(event, encode(payload));
168
+ }
169
+
170
+ /**
171
+ * Answer one page invoke. Called by the generated entry, which the shell
172
+ * calls through the library's C ABI.
173
+ *
174
+ * A command that throws is caught here and reported as a rejection rather
175
+ * than taking the app down with it — on iOS an uncaught throw would abort
176
+ * the process.
177
+ */
178
+ dispatch(cmd: string, argsJson: string): string {
179
+ try {
180
+ const args = JSON.parse(argsJson) as unknown;
181
+ for (let i = 0; i < this.names.length; i++) {
182
+ if (this.names[i] === cmd) {
183
+ this.deferred = -1;
184
+ const value = this.handlers[i](args);
185
+ // An async command parked its answer during the call above. Tell the
186
+ // shell to hold the page's reply under that id instead of settling
187
+ // now; hostSettle() answers it later.
188
+ if (this.deferred >= 0) {
189
+ const held = this.deferred;
190
+ this.deferred = -1;
191
+ return encode({ pending: held });
192
+ }
193
+ return encode({ ok: true, value });
194
+ }
195
+ }
196
+ return encode({ ok: false, error: "unknown command: " + cmd });
197
+ } catch (e) {
198
+ return encode({ ok: false, error: (e as Error).message });
199
+ }
200
+ }
201
+
202
+ /** The document the shell should load. Set by the generated entry. */
203
+ setHtml(html: string): void {
204
+ this.html = html;
205
+ }
206
+
207
+ indexHtml(): string {
208
+ return this.html;
209
+ }
210
+
211
+ // -------------------------------------------------------------------------
212
+ // Not yet on iOS
213
+ // -------------------------------------------------------------------------
214
+ // Present so that a project written for desktop still COMPILES for iOS —
215
+ // the typed contract and main.ts are shared source. Each reports clearly at
216
+ // the point of use instead of doing nothing quietly, and each routes through
217
+ // pending() above so there is one place to change.
218
+
219
+ /**
220
+ * An async command: answer later, without freezing the window.
221
+ *
222
+ * The handler runs during dispatch() but need not produce a value. It parks
223
+ * a pending id; dispatch() returns that id to the shell, which holds the
224
+ * page's promise until resolve/reject settles it. Same contract as desktop.
225
+ */
226
+ commandAsync<K extends keyof C & string>(
227
+ name: K,
228
+ handler: (
229
+ args: C[K]["args"],
230
+ resolve: (value: C[K]["result"]) => void,
231
+ reject: (reason: unknown) => void,
232
+ ) => void,
233
+ ): void {
234
+ this.names.push(name);
235
+ this.handlers.push((args: unknown) => {
236
+ const id = this.nextPending;
237
+ this.nextPending = id + 1;
238
+ this.pendingIds.push(id);
239
+ // dispatch() reads this immediately after the handler returns.
240
+ this.deferred = id;
241
+ handler(
242
+ args as C[K]["args"],
243
+ (value: C[K]["result"]) => {
244
+ this.settle(id, encode({ ok: true, value: value }));
245
+ },
246
+ (reason: unknown) => {
247
+ this.settle(id, encode({ ok: false, error: String(reason) }));
248
+ },
249
+ );
250
+ return null;
251
+ });
252
+ }
253
+
254
+ /** Settle a held reply once, ignoring a second resolve/reject. */
255
+ settle(id: number, envelope: string): void {
256
+ for (let i = 0; i < this.pendingIds.length; i++) {
257
+ if (this.pendingIds[i] === id) {
258
+ this.pendingIds.splice(i, 1);
259
+ hostSettle(id, envelope);
260
+ return;
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Park `fn` with the shell and ask to be called back in `ms`.
267
+ *
268
+ * The id is the whole protocol: TS keeps the closure, the shell keeps the
269
+ * clock. Identical to the desktop lane.
270
+ */
271
+ schedule(ms: number, fn: () => void): void {
272
+ const id = this.nextCont;
273
+ this.nextCont = id + 1;
274
+ this.contIds.push(id);
275
+ this.contFns.push(fn);
276
+ const delay = ms > 0 ? ms : 0;
277
+ hostSchedule(id, delay);
278
+ }
279
+
280
+ /** Run fn on the next turn of the host loop — no timer involved. */
281
+ defer(fn: () => void): void {
282
+ this.schedule(0, fn);
283
+ }
284
+
285
+ /** Run fn after roughly `ms`, without blocking the window meanwhile. */
286
+ sleep(ms: number, fn: () => void): void {
287
+ this.schedule(ms, fn);
288
+ }
289
+
290
+ /**
291
+ * Called by the shell on the main queue when a continuation comes due.
292
+ *
293
+ * This always lands at the top of a fresh turn with no TS frame beneath it,
294
+ * because the shell posts through its own queue rather than calling back
295
+ * from inside a channel handler (upstream #263: a breach silently appears
296
+ * to work, so it has to hold by construction).
297
+ */
298
+ onTimer(id: number): void {
299
+ for (let i = 0; i < this.contIds.length; i++) {
300
+ if (this.contIds[i] === id) {
301
+ const fn = this.contFns[i];
302
+ // Unregister BEFORE running: a continuation that schedules another one
303
+ // must not disturb the entry being removed.
304
+ this.contIds.splice(i, 1);
305
+ this.contFns.splice(i, 1);
306
+ fn();
307
+ return;
308
+ }
309
+ }
310
+ }
311
+
312
+ /** Read a file without blocking the UI; the shell does the I/O off-queue. */
313
+ readFileAsync(path: string, cb: FsCallback): void {
314
+ const id = this.nextJob;
315
+ this.nextJob = id + 1;
316
+ this.jobIds.push(id);
317
+ this.jobCbs.push(cb);
318
+ this.jobWriteCbs.push(nullWrite);
319
+ hostReadFile(id, path);
320
+ }
321
+
322
+ /** Write a file without blocking the UI; the shell does the I/O off-queue. */
323
+ writeFileAsync(path: string, data: string, cb: (err: string | null) => void): void {
324
+ const id = this.nextJob;
325
+ this.nextJob = id + 1;
326
+ this.jobIds.push(id);
327
+ this.jobCbs.push(nullRead);
328
+ this.jobWriteCbs.push(cb);
329
+ hostWriteFile(id, path, data);
330
+ }
331
+
332
+ /**
333
+ * Called by the shell on the main queue when a file job finishes. `payload`
334
+ * carries the contents on a read, or the error message when `ok` is false.
335
+ */
336
+ onFsDone(id: number, ok: boolean, payload: string): void {
337
+ for (let i = 0; i < this.jobIds.length; i++) {
338
+ if (this.jobIds[i] === id) {
339
+ const readCb = this.jobCbs[i];
340
+ const writeCb = this.jobWriteCbs[i];
341
+ this.jobIds.splice(i, 1);
342
+ this.jobCbs.splice(i, 1);
343
+ this.jobWriteCbs.splice(i, 1);
344
+ if (readCb !== nullRead) {
345
+ if (ok) readCb(null, payload);
346
+ else readCb(payload, "");
347
+ } else {
348
+ writeCb(ok ? null : payload);
349
+ }
350
+ return;
351
+ }
352
+ }
353
+ }
354
+
355
+ /** @remarks Not on iOS yet; reports through the callback. */
356
+ openFileDialog(
357
+ _options: OpenDialogOptions,
358
+ cb: (paths: string[] | null, err?: string) => void,
359
+ ): void {
360
+ cb(null, pending("openFileDialog", "iOS needs a document picker"));
361
+ }
362
+
363
+ /** @remarks Not on iOS yet; reports through the callback. */
364
+ saveFileDialog(
365
+ _options: SaveDialogOptions,
366
+ cb: (path: string | null, err?: string) => void,
367
+ ): void {
368
+ cb(null, pending("saveFileDialog", "iOS needs a document picker"));
369
+ }
370
+
371
+ /** @remarks No-op on iOS: an app has no window title to set. */
372
+ setTitle(_title: string): void {
373
+ console.error(pending("setTitle", "an iOS app has no window title"));
374
+ }
375
+
376
+ /** @remarks No-op on iOS: an app fills the screen. */
377
+ setSize(_width: number, _height: number, _hint?: number): void {
378
+ console.error(pending("setSize", "an iOS app fills the screen"));
379
+ }
380
+
381
+ /** @remarks No-op on iOS: an app is always fullscreen. */
382
+ setFullscreen(_on: boolean): void {
383
+ console.error(pending("setFullscreen", "an iOS app is always fullscreen"));
384
+ }
385
+
386
+ /** @remarks No-op on iOS: apps are dismissed by the user, not by code. */
387
+ quit(): void {
388
+ console.error(pending("quit", "iOS apps are dismissed by the user"));
389
+ }
390
+
391
+ /**
392
+ * Present for source compatibility with the desktop lane; UIKit owns the run
393
+ * loop here, so the shell shows the page rather than this.
394
+ */
395
+ run(html: string): number {
396
+ this.setHtml(html);
397
+ return 0;
398
+ }
399
+ }
400
+
401
+ // The public host types live in ./types (shipped as `janela/host` too, so a
402
+ // user's editor can see them). Re-exported here because the compiled build
403
+ // resolves them through this module — see the specifier rewrite in the CLI.
404
+ export type {
405
+ ArgsOf,
406
+ AsyncCommandHandler,
407
+ CommandHandler,
408
+ CommandShape,
409
+ CommandShapes,
410
+ CommandSpec,
411
+ CommandSpecs,
412
+ Commands,
413
+ Norm,
414
+ ResultOf,
415
+ DialogFilter,
416
+ Events,
417
+ FsCallback,
418
+ OpenDialogOptions,
419
+ SaveDialogOptions,
420
+ WindowConfig,
421
+ } from "./types";
422
+
423
+ export { defineCommands, defineEvents } from "./types";
424
+
425
+ /**
426
+ * A running janela app, typed by the contract it serves. See the desktop lane
427
+ * for the full explanation; the alias exists so a contract may be written as
428
+ * plain function types.
429
+ */
430
+ export type JanelaApp<
431
+ C extends CommandSpecs = CommandShapes,
432
+ E = Record<string, unknown>,
433
+ > = JanelaAppImpl<Norm<C>, E>;
434
+
435
+ export function createApp<
436
+ C extends CommandShapes = CommandShapes,
437
+ E = Record<string, unknown>,
438
+ >(cfg: WindowConfig): JanelaAppImpl<C, E> {
439
+ return new JanelaAppImpl<C, E>(cfg);
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Deprecated standalone registrars (0.5.x)
444
+ // ---------------------------------------------------------------------------
445
+
446
+ /** @deprecated Use `app.command(name, handler)` on a contract-typed app. */
447
+ export function on<M extends CommandShapes, K extends keyof M & string>(
448
+ app: JanelaAppImpl,
449
+ _commands: unknown,
450
+ name: K,
451
+ handler: (args: M[K]["args"]) => M[K]["result"],
452
+ ): void {
453
+ app.command(name, (args: unknown) => handler(args as M[K]["args"]));
454
+ }
455
+
456
+ /** @deprecated Use `app.commandAsync(name, handler)` on a contract-typed app. */
457
+ export function onAsync<M extends CommandShapes, K extends keyof M & string>(
458
+ app: JanelaAppImpl,
459
+ _commands: unknown,
460
+ name: K,
461
+ handler: (
462
+ args: M[K]["args"],
463
+ resolve: (value: M[K]["result"]) => void,
464
+ reject: (reason: unknown) => void,
465
+ ) => void,
466
+ ): void {
467
+ app.commandAsync(
468
+ name,
469
+ (args: unknown, resolve: (v: unknown) => void, reject: (r: unknown) => void) => {
470
+ handler(args as M[K]["args"], (value: M[K]["result"]) => resolve(value), reject);
471
+ },
472
+ );
473
+ }
474
+
475
+ /** @deprecated Use `app.emit(event, payload)` on a contract-typed app. */
476
+ export function emit<E, K extends keyof E & string>(
477
+ app: JanelaAppImpl,
478
+ _events: unknown,
479
+ name: K,
480
+ payload: E[K],
481
+ ): void {
482
+ app.emit(name, payload as unknown);
483
+ }