@zappdev/cli 0.5.0-alpha.9 → 0.6.0-alpha.1

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.
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Generates two functions:
6
6
  * - zapp_webview_bootstrap_script() — WebView bridge (invoke, events, workers, sync, drag)
7
- * - zapp_worker_bootstrap_script() — Worker bridge (channels, sync dispatch)
7
+ * - zapp_worker_bootstrap_script() — Unified worker bridge (used by both webview-spawned
8
+ * workers and headless workers)
8
9
  *
9
10
  * Usage: called by CLI during build, or standalone:
10
11
  * bun bootstrap/codegen.ts [outputDir]
@@ -45,14 +46,13 @@ export async function generateBootstrap(outputDir: string): Promise<string> {
45
46
  await mkdir(outputDir, { recursive: true });
46
47
 
47
48
  // Bundle all bootstraps in parallel
48
- const [webviewEscaped, workerEscaped, backendEscaped] = await Promise.all([
49
+ const [webviewEscaped, workerEscaped] = await Promise.all([
49
50
  bundleAndEscape(path.join(bootstrapDir, "webview.ts")),
50
51
  bundleAndEscape(path.join(bootstrapDir, "worker.ts")),
51
- bundleAndEscape(path.join(bootstrapDir, "backend.ts")),
52
52
  ]);
53
53
 
54
54
  const zcContent = `// AUTO-GENERATED by zapp CLI. Do not edit.
55
- // Source: bootstrap/webview.ts, bootstrap/worker.ts, bootstrap/backend.ts
55
+ // Source: bootstrap/webview.ts, bootstrap/worker.ts
56
56
 
57
57
  fn zapp_webview_bootstrap_script() -> string {
58
58
  raw { return "${webviewEscaped}"; }
@@ -63,11 +63,6 @@ fn zapp_worker_bootstrap_script() -> string {
63
63
  raw { return "${workerEscaped}"; }
64
64
  return "";
65
65
  }
66
-
67
- fn zapp_backend_bootstrap_script() -> string {
68
- raw { return "${backendEscaped}"; }
69
- return "";
70
- }
71
66
  `;
72
67
 
73
68
  const outPath = path.join(outputDir, "zapp_bootstrap.zc");
@@ -1,25 +1,112 @@
1
1
  /**
2
- * Worker bootstrap — injected into JSC worker contexts after host objects are set up.
2
+ * Worker bootstrap — injected into every JSC worker context (webview-spawned
3
+ * and headless alike). All workers share the same API surface; the only
4
+ * difference between a headless worker and a webview-owned one is whether
5
+ * they have an owner window.
3
6
  *
4
- * Host objects already available on __zappBridge:
7
+ * Host objects set up by native (see jsc_setup_bridge in jsc.m):
5
8
  * - invokeService(method, args) → JSValue (sync, direct C call)
6
- * - postToWebview(data) → void
7
- * - syncWait(key, timeoutMs) → void (fires async, result via dispatchSyncResult)
8
- * - syncNotify(key, count) void
9
+ * - postToWebview(data) → void (no-op in headless workers)
10
+ * - syncWait(key, timeoutMs), syncNotify(key, count)
11
+ * - dispatchEventToAll(name, payload) broadcast to every webview
12
+ * - createWindow(opts) → number (returns windowId; works from any worker)
13
+ * - quit() — terminate the app
14
+ * - showNotification(title, body)
15
+ * - subscribeWindowEvent(windowId, eventId)
9
16
  *
10
- * This bootstrap adds JS-side convenience APIs:
11
- * - self.send(channel, data) / self.receive(channel, handler) — named channels
12
- * - Channel routing in _messageHandlers (called by ObjC post_message dispatch)
13
- * - self.postMessage wrapper
17
+ * This bootstrap adds:
18
+ * - A runtime bridge exposed via Symbol.for("zapp.bridge") so
19
+ * @zappdev/runtime's getBridge() works uniformly across contexts.
20
+ * - Channel API (self.send / self.receive) for webview-worker messaging.
21
+ * - App event listener registry + _dispatchAppEvent callback for headless
22
+ * workers that want to hear app:* lifecycle events.
23
+ * - dispatchSyncResult glue for Sync.wait()'s promise resolution.
14
24
  */
15
25
 
16
26
  (function () {
17
27
  const bridge = (self as any).__zappBridge;
18
28
  if (!bridge) return;
19
29
 
20
- // Expose under Symbol.for("zapp.bridge") so @zappdev/runtime's getBridge()
21
- // works in workers. Host objects on the bridge (invokeService, syncWait,
22
- // syncNotify, postToWebview, dispatchEventToAll) are accessed directly.
30
+ // Zero-overhead principle: we mutate __zappBridge in place to add the
31
+ // methods the runtime expects (on, emit, invoke, post, _onEvent,
32
+ // _dispatchAppEvent) and expose *that same object* under
33
+ // Symbol.for("zapp.bridge"). No wrapper, no indirection — when the
34
+ // runtime calls getBridge().invoke(...), it's hitting the native host
35
+ // object's own property directly.
36
+ //
37
+ // The hottest path (Services.invoke → __zappBridge.invokeService) already
38
+ // bypasses getBridge() entirely in runtime/services.ts, so it remains a
39
+ // direct C call regardless. This setup ensures the remaining
40
+ // runtime-API calls from worker contexts pay at most one JS property
41
+ // read + function call, not a whole wrapper object hop.
42
+
43
+ const listeners: Record<string, Array<(data: unknown) => void>> = {};
44
+
45
+ const windowEventIds: Record<string, number> = {
46
+ "window:ready": 0, "window:focus": 1, "window:blur": 2,
47
+ "window:resize": 3, "window:move": 4, "window:close": 5,
48
+ "window:minimize": 6, "window:maximize": 7, "window:restore": 8,
49
+ "window:fullscreen": 9, "window:unfullscreen": 10,
50
+ };
51
+
52
+ bridge.on = function (event: string, handler: (data: unknown) => void) {
53
+ if (!listeners[event]) listeners[event] = [];
54
+ listeners[event].push(handler);
55
+ const eventId = windowEventIds[event];
56
+ if (eventId !== undefined && typeof bridge.subscribeWindowEvent === "function") {
57
+ bridge.subscribeWindowEvent(-1, eventId); // -1 = all windows
58
+ }
59
+ return () => {
60
+ listeners[event] = (listeners[event] || []).filter((h) => h !== handler);
61
+ };
62
+ };
63
+
64
+ // Runtime's Events.emit calls bridge.emit — alias to the direct host.
65
+ // No wrapper, just rename.
66
+ bridge.emit = bridge.dispatchEventToAll;
67
+
68
+ // Runtime's getBridge().invoke() — pure alias to invokeService (zero JS
69
+ // overhead). Runtime APIs that need something other than a user service
70
+ // (Window.create, Notification.*, Dock.*) detect worker context and call
71
+ // the appropriate host dispatcher directly, so there's no branching here.
72
+ bridge.invoke = bridge.invokeService;
73
+
74
+ // Headless workers have no webview to post to; webview-owned workers
75
+ // use postToWebview. Alias so getBridge().post(...) works either way.
76
+ bridge.post = bridge.postToWebview ?? function () {};
77
+
78
+ // Native-driven event dispatch callbacks.
79
+ bridge._dispatchAppEvent = function (eventId: number, dataJson: string) {
80
+ const eventMap: Record<number, string> = {
81
+ 100: "app:started", 101: "app:shutdown",
82
+ 102: "app:notification-click", 103: "app:notification-action",
83
+ 104: "app:reopen", 105: "app:open-url",
84
+ 106: "app:active", 107: "app:inactive",
85
+ };
86
+ const name = eventMap[eventId];
87
+ if (!name) return;
88
+ let data: unknown = dataJson;
89
+ try { data = JSON.parse(dataJson); } catch {}
90
+ for (const h of listeners[name] || []) {
91
+ try { h(data); } catch (e) { console.error("[worker]", e); }
92
+ }
93
+ if (eventId === 102) {
94
+ for (const h of listeners["__notif:click"] || []) try { h(data); } catch (e) { console.error("[worker]", e); }
95
+ } else if (eventId === 103) {
96
+ for (const h of listeners["__notif:action"] || []) try { h(data); } catch (e) { console.error("[worker]", e); }
97
+ }
98
+ };
99
+
100
+ bridge._onEvent = function (name: string, payload: string) {
101
+ let parsed: unknown = payload;
102
+ try { parsed = JSON.parse(payload); } catch {}
103
+ for (const h of listeners[name] || []) {
104
+ try { h(parsed); } catch (e) { console.error("[worker]", e); }
105
+ }
106
+ };
107
+
108
+ // Expose __zappBridge itself (not a wrapper) under the symbol the
109
+ // runtime looks up. getBridge() returns the native host object directly.
23
110
  (globalThis as any)[Symbol.for("zapp.bridge")] = bridge;
24
111
 
25
112
  // Channel handler registry
package/dist/config.js ADDED
@@ -0,0 +1,25 @@
1
+ // src/config.ts
2
+ import path from "node:path";
3
+ function defineConfig(config) {
4
+ return config;
5
+ }
6
+ async function loadConfig(root) {
7
+ const configPath = path.join(root, "zapp.config.ts");
8
+ try {
9
+ const mod = await import(configPath);
10
+ const config = typeof mod.default === "function" ? mod.default() : mod.default;
11
+ return {
12
+ ...config,
13
+ assetDir: config.assetDir ?? "./dist"
14
+ };
15
+ } catch {
16
+ return {
17
+ name: path.basename(root),
18
+ assetDir: "./dist"
19
+ };
20
+ }
21
+ }
22
+ export {
23
+ loadConfig,
24
+ defineConfig
25
+ };
package/native/app/app.zc CHANGED
@@ -66,10 +66,35 @@ import "../worker/engines/jsc.h" as jsc_engine;
66
66
  @cfg(ZAPP_WORKER_ENGINE_TXIKI)
67
67
  import "../worker/engines/txiki.h" as txiki_engine;
68
68
 
69
- @cfg(ZAPP_WORKER_ENGINE_TXIKI)
70
- fn app_start_backend() -> void { txiki_engine::txiki_backend_create("/_workers/backend.mjs"); }
71
- @cfg(ZAPP_WORKER_ENGINE_JSC)
72
- fn app_start_backend() -> void { jsc_engine::jsc_backend_create("/_workers/backend.mjs"); }
69
+ // zapp_start_headless_workers is provided by .zapp/zapp_headless_workers.zc,
70
+ // generated by the CLI from zappConfig.headless. When no headless workers are
71
+ // configured the generated function is an empty body.
72
+ extern fn zapp_start_headless_workers() -> void;
73
+
74
+ // Helper the generated headless startup file calls for each configured worker.
75
+ // Owner ID is empty — headless workers have no parent window.
76
+ fn zapp_start_headless_worker(id: string, script_url: string) -> void {
77
+ let app = (App*)app_get_active();
78
+ if app == NULL { return; }
79
+ worker_create(app, script_url, "", id);
80
+ }
81
+
82
+ // Called from workers via the createWindow host object. Always runs on the
83
+ // main queue (the ObjC-side dispatch_sync handles that). Builds a
84
+ // WindowOptions and creates the window using the normal WindowManager path.
85
+ fn zapp_worker_create_window(title: string, url: string, width: int, height: int) -> int {
86
+ let app = (App*)app_get_active();
87
+ if app == NULL { return -1; }
88
+ let opts = WindowOptions::create(title);
89
+ raw {
90
+ if (url && url[0] != '\0') opts.url = url;
91
+ }
92
+ if width > 0 { opts.width = width; }
93
+ if height > 0 { opts.height = height; }
94
+ opts.visible = true;
95
+ let win = app.window.create(&opts);
96
+ return win.id;
97
+ }
73
98
 
74
99
  // Dispatch worker message to a single window by owner_id
75
100
  fn worker_dispatch_to_window(worker_id: string, data_json: string, owner_id: string) -> void {
@@ -182,7 +207,6 @@ struct AppConfig {
182
207
  webContentInspectable: ZappInspectable;
183
208
  maxWorkers: int;
184
209
  qjsStackSize: int;
185
- backend: bool; // enable backend worker (src/backend.ts)
186
210
  }
187
211
 
188
212
  // ---------------------------------------------------------------------------
@@ -247,13 +271,12 @@ impl App {
247
271
  self.window.app = (App*)self;
248
272
  app_set_active((App*)self);
249
273
 
250
- // Run service startup (in registration order, before windows/backend)
274
+ // Run service startup (in registration order, before windows)
251
275
  service_run_startup_all();
252
276
 
253
- // Start backend worker (if enabled)
254
- if self.config.backend {
255
- app_start_backend();
256
- }
277
+ // Start configured headless workers. Generated function — empty body
278
+ // when zappConfig.headless is empty or omitted.
279
+ zapp_start_headless_workers();
257
280
 
258
281
  return platform_run(self.config.applicationShouldTerminateAfterLastWindowClosed);
259
282
  }
@@ -52,7 +52,9 @@ raw {
52
52
  }
53
53
  }
54
54
 
55
- // Layer 2: Backend worker (if running)
55
+ // Layer 2: Broadcast to every active worker (headless + webview-owned).
56
+ // Each worker's bootstrap sets up `_dispatchAppEvent` on __zappBridge
57
+ // and fans the event out to any listeners registered via bridge.on().
56
58
  {
57
59
  const char* safe_data = data ? data : "";
58
60
  char buf[4096];
@@ -62,18 +64,12 @@ raw {
62
64
  event_id, safe_data);
63
65
 
64
66
  #if defined(ZAPP_WORKER_ENGINE_TXIKI)
65
- extern bool txiki_backend_is_running(void);
66
- extern void txiki_backend_eval_js(const char* js);
67
- if (txiki_backend_is_running()) {
68
- txiki_backend_eval_js(buf);
69
- }
67
+ extern void txiki_broadcast_eval_js(const char* js);
68
+ txiki_broadcast_eval_js(buf);
70
69
  #endif
71
70
  #if defined(ZAPP_WORKER_ENGINE_JSC)
72
- extern bool jsc_backend_is_running(void);
73
- extern void jsc_backend_eval_js(const char* js);
74
- if (jsc_backend_is_running()) {
75
- jsc_backend_eval_js(buf);
76
- }
71
+ extern void jsc_broadcast_eval_js(const char* js);
72
+ jsc_broadcast_eval_js(buf);
77
73
  #endif
78
74
  }
79
75
 
package/native/build.zc CHANGED
@@ -37,7 +37,6 @@ fn zapp_build_accept_first_mouse() -> int { return 1; } // drag regions
37
37
  // Bootstrap stub (standalone — normally generated by CLI via bootstrap/codegen.ts)
38
38
  fn zapp_webview_bootstrap_script() -> string { return ""; }
39
39
  fn zapp_worker_bootstrap_script() -> string { return ""; }
40
- fn zapp_backend_bootstrap_script() -> string { return ""; }
41
40
 
42
41
  // --- Test entry point ---
43
42
 
@@ -56,7 +55,6 @@ fn main() -> int {
56
55
  webContentInspectable: Zapp::inspectable_on(),
57
56
  maxWorkers: 0,
58
57
  qjsStackSize: 0,
59
- backend: false,
60
58
  };
61
59
  let app = App::new(config);
62
60
 
@@ -149,14 +149,13 @@ raw {
149
149
  ename, window_id);
150
150
  }
151
151
 
152
+ // Broadcast to every active worker (headless + webview-owned)
152
153
  #if defined(ZAPP_WORKER_ENGINE_TXIKI)
153
- extern bool txiki_backend_is_running(void);
154
- extern void txiki_backend_eval_js(const char* js);
155
- if (txiki_backend_is_running()) txiki_backend_eval_js(__be_buf);
154
+ extern void txiki_broadcast_eval_js(const char* js);
155
+ txiki_broadcast_eval_js(__be_buf);
156
156
  #elif defined(ZAPP_WORKER_ENGINE_JSC)
157
- extern bool jsc_backend_is_running(void);
158
- extern void jsc_backend_eval_js(const char* js);
159
- if (jsc_backend_is_running()) jsc_backend_eval_js(__be_buf);
157
+ extern void jsc_broadcast_eval_js(const char* js);
158
+ jsc_broadcast_eval_js(__be_buf);
160
159
  #endif
161
160
  #endif
162
161
  }
@@ -24,18 +24,4 @@ void jsc_worker_terminate_owner(const char* owner_id);
24
24
  // Implemented in Zen-C (bridge/dispatch.zc), declared here for jsc.m to call.
25
25
  extern void worker_dispatch_to_webview(char* worker_id, char* data_json);
26
26
 
27
- // --- Backend worker (privileged, app-level JS context) ---
28
-
29
- // Create the backend worker. Loads script from path. Starts on serial queue.
30
- bool jsc_backend_create(const char* script_path);
31
-
32
- // Terminate the backend worker.
33
- void jsc_backend_terminate(void);
34
-
35
- // Evaluate JS in the backend worker context.
36
- void jsc_backend_eval_js(const char* js);
37
-
38
- // Check if backend worker is running.
39
- bool jsc_backend_is_running(void);
40
-
41
27
  #endif
@@ -294,6 +294,180 @@ static void jsc_setup_bridge(JSContext* ctx, NSString* workerId) {
294
294
  darwin_sync_handle("notify", [json UTF8String]);
295
295
  };
296
296
 
297
+ // --- Privileged host objects available in every worker ---
298
+
299
+ // quit — terminate the app from any worker
300
+ bridge[@"quit"] = ^{
301
+ dispatch_async(dispatch_get_main_queue(), ^{
302
+ exit(0);
303
+ });
304
+ };
305
+
306
+ // subscribeWindowEvent — register this worker for window events
307
+ bridge[@"subscribeWindowEvent"] = ^(JSValue* windowIdVal, JSValue* eventIdVal) {
308
+ int wId = [windowIdVal toInt32];
309
+ int eId = [eventIdVal toInt32];
310
+ dispatch_async(dispatch_get_main_queue(), ^{
311
+ extern void zapp_window_set_backend_listener(int id, int event_id, int has_listener);
312
+ if (wId < 0) {
313
+ for (int i = 0; i < 64; i++) {
314
+ zapp_window_set_backend_listener(i, eId, 1);
315
+ }
316
+ } else {
317
+ zapp_window_set_backend_listener(wId, eId, 1);
318
+ }
319
+ });
320
+ };
321
+
322
+ // showNotification — fire-and-forget system notification
323
+ bridge[@"showNotification"] = ^(NSString* title, NSString* body) {
324
+ const char* t = title ? [title UTF8String] : "";
325
+ const char* b = body ? [body UTF8String] : "";
326
+ char* tc = strdup(t);
327
+ char* bc = strdup(b);
328
+ dispatch_async(dispatch_get_main_queue(), ^{
329
+ extern void darwin_notification_show_typed(const char*, const char*, const char*, const char*);
330
+ darwin_notification_show_typed(tc, "", bc, "default");
331
+ free(tc);
332
+ free(bc);
333
+ });
334
+ };
335
+
336
+ // createWindow — synchronously create a window from any worker.
337
+ // Dispatches to main queue (window creation requires main thread on macOS),
338
+ // waits for result, returns an object with windowId.
339
+ bridge[@"createWindow"] = ^JSValue*(JSValue* optsVal) {
340
+ JSContext* currentCtx = [JSContext currentContext];
341
+ NSString* title = @"Window";
342
+ NSString* url = @"";
343
+ __block int width = 0;
344
+ __block int height = 0;
345
+ if (optsVal && ![optsVal isUndefined] && ![optsVal isNull]) {
346
+ JSValue* t = optsVal[@"title"];
347
+ if (t && ![t isUndefined] && ![t isNull]) title = [t toString];
348
+ JSValue* u = optsVal[@"url"];
349
+ if (u && ![u isUndefined] && ![u isNull]) url = [u toString];
350
+ JSValue* w = optsVal[@"width"];
351
+ if (w && ![w isUndefined] && ![w isNull]) width = [w toInt32];
352
+ JSValue* h = optsVal[@"height"];
353
+ if (h && ![h isUndefined] && ![h isNull]) height = [h toInt32];
354
+ }
355
+ const char* titleC = strdup([title UTF8String]);
356
+ const char* urlC = strdup([url UTF8String]);
357
+ __block int windowId = -1;
358
+ dispatch_sync(dispatch_get_main_queue(), ^{
359
+ extern int zapp_worker_create_window(const char* title, const char* url, int width, int height);
360
+ windowId = zapp_worker_create_window(titleC, urlC, width, height);
361
+ });
362
+ free((void*)titleC);
363
+ free((void*)urlC);
364
+ JSValue* result = [JSValue valueWithNewObjectInContext:currentCtx];
365
+ result[@"windowId"] = [NSString stringWithFormat:@"win-%d", windowId];
366
+ return result;
367
+ };
368
+
369
+ // notif(action, args) — dispatcher for all notification operations.
370
+ // Runtime's Notification.* methods call this directly in worker contexts,
371
+ // bypassing getBridge().invoke() entirely for zero-overhead calls.
372
+ bridge[@"notif"] = ^JSValue*(NSString* action, JSValue* argsVal) {
373
+ JSContext* currentCtx = [JSContext currentContext];
374
+ extern const char* darwin_notification_get_permission(void);
375
+ extern void darwin_notification_show_typed(const char*, const char*, const char*, const char*);
376
+ extern void darwin_notification_schedule_typed(const char*, const char*, double);
377
+ extern void darwin_notification_cancel(const char*);
378
+ extern void darwin_notification_cancel_all(void);
379
+ extern void darwin_notification_remove_delivered(const char*);
380
+ extern void darwin_notification_remove_all_delivered(void);
381
+ extern void darwin_notification_update(const char*, const char*, const char*, const char*);
382
+
383
+ NSString* act = action ?: @"";
384
+ if ([act isEqualToString:@"getPermission"]) {
385
+ const char* st = darwin_notification_get_permission();
386
+ JSValue* r = [JSValue valueWithNewObjectInContext:currentCtx];
387
+ r[@"status"] = st ? [NSString stringWithUTF8String:st] : @"notDetermined";
388
+ return r;
389
+ }
390
+ if ([act isEqualToString:@"show"]) {
391
+ NSString* title = argsVal[@"title"] ? [argsVal[@"title"] toString] : @"";
392
+ NSString* subtitle = argsVal[@"subtitle"] ? [argsVal[@"subtitle"] toString] : @"";
393
+ NSString* body = argsVal[@"body"] ? [argsVal[@"body"] toString] : @"";
394
+ NSString* sound = argsVal[@"sound"] ? [argsVal[@"sound"] toString] : @"default";
395
+ darwin_notification_show_typed([title UTF8String], [subtitle UTF8String], [body UTF8String], [sound UTF8String]);
396
+ // Return an ID (clients use it for update/cancel). Workers don't get the native-generated ID in this
397
+ // sync path — generate a client-side UUID-ish value so the API contract holds.
398
+ JSValue* r = [JSValue valueWithNewObjectInContext:currentCtx];
399
+ r[@"id"] = [NSString stringWithFormat:@"notif-%llu-%u", (unsigned long long)[[NSDate date] timeIntervalSince1970] * 1000, arc4random()];
400
+ return r;
401
+ }
402
+ if ([act isEqualToString:@"schedule"]) {
403
+ NSString* title = argsVal[@"title"] ? [argsVal[@"title"] toString] : @"";
404
+ NSString* body = argsVal[@"body"] ? [argsVal[@"body"] toString] : @"";
405
+ double delay = argsVal[@"delaySeconds"] ? [argsVal[@"delaySeconds"] toDouble] : 0;
406
+ darwin_notification_schedule_typed([title UTF8String], [body UTF8String], delay);
407
+ JSValue* r = [JSValue valueWithNewObjectInContext:currentCtx];
408
+ r[@"id"] = [NSString stringWithFormat:@"notif-%llu-%u", (unsigned long long)[[NSDate date] timeIntervalSince1970] * 1000, arc4random()];
409
+ return r;
410
+ }
411
+ if ([act isEqualToString:@"cancel"]) {
412
+ NSString* id_ = argsVal[@"id"] ? [argsVal[@"id"] toString] : @"";
413
+ darwin_notification_cancel([id_ UTF8String]);
414
+ return [JSValue valueWithUndefinedInContext:currentCtx];
415
+ }
416
+ if ([act isEqualToString:@"cancelAll"]) {
417
+ darwin_notification_cancel_all();
418
+ return [JSValue valueWithUndefinedInContext:currentCtx];
419
+ }
420
+ if ([act isEqualToString:@"removeDelivered"]) {
421
+ NSString* id_ = argsVal[@"id"] ? [argsVal[@"id"] toString] : @"";
422
+ darwin_notification_remove_delivered([id_ UTF8String]);
423
+ return [JSValue valueWithUndefinedInContext:currentCtx];
424
+ }
425
+ if ([act isEqualToString:@"removeAllDelivered"]) {
426
+ darwin_notification_remove_all_delivered();
427
+ return [JSValue valueWithUndefinedInContext:currentCtx];
428
+ }
429
+ if ([act isEqualToString:@"update"]) {
430
+ NSString* id_ = argsVal[@"id"] ? [argsVal[@"id"] toString] : @"";
431
+ NSString* title = argsVal[@"title"] ? [argsVal[@"title"] toString] : @"";
432
+ NSString* subtitle = argsVal[@"subtitle"] ? [argsVal[@"subtitle"] toString] : @"";
433
+ NSString* body = argsVal[@"body"] ? [argsVal[@"body"] toString] : @"";
434
+ darwin_notification_update([id_ UTF8String], [title UTF8String], [subtitle UTF8String], [body UTF8String]);
435
+ return [JSValue valueWithUndefinedInContext:currentCtx];
436
+ }
437
+ return [JSValue valueWithUndefinedInContext:currentCtx];
438
+ };
439
+
440
+ // dock(action, args) — dispatcher for all dock operations. All are sync,
441
+ // fire-and-forget (they post to the main UI thread internally via the
442
+ // Zen-C bridge and return immediately).
443
+ bridge[@"dock"] = ^(NSString* action, JSValue* argsVal) {
444
+ extern void darwin_dock_show_icon(void);
445
+ extern void darwin_dock_hide_icon(void);
446
+ extern void darwin_dock_set_badge(const char*);
447
+ extern void darwin_dock_remove_badge(void);
448
+ extern void darwin_dock_bounce(int);
449
+ extern void darwin_dock_set_icon(const char*);
450
+ extern void darwin_dock_reset_icon(void);
451
+
452
+ NSString* act = action ?: @"";
453
+ if ([act isEqualToString:@"showIcon"]) { darwin_dock_show_icon(); return; }
454
+ if ([act isEqualToString:@"hideIcon"]) { darwin_dock_hide_icon(); return; }
455
+ if ([act isEqualToString:@"setBadge"]) {
456
+ NSString* label = argsVal[@"label"] ? [argsVal[@"label"] toString] : @"";
457
+ darwin_dock_set_badge([label UTF8String]); return;
458
+ }
459
+ if ([act isEqualToString:@"removeBadge"]) { darwin_dock_remove_badge(); return; }
460
+ if ([act isEqualToString:@"bounce"]) {
461
+ int t = argsVal[@"type"] ? [argsVal[@"type"] toInt32] : 0;
462
+ darwin_dock_bounce(t); return;
463
+ }
464
+ if ([act isEqualToString:@"setIcon"]) {
465
+ NSString* p = argsVal[@"path"] ? [argsVal[@"path"] toString] : @"";
466
+ darwin_dock_set_icon([p UTF8String]); return;
467
+ }
468
+ if ([act isEqualToString:@"resetIcon"]) { darwin_dock_reset_icon(); return; }
469
+ };
470
+
297
471
  ctx[@"__zappBridge"] = bridge;
298
472
 
299
473
  // setTimeout / setInterval
@@ -555,165 +729,21 @@ void jsc_worker_terminate_owner(const char* owner_id) {
555
729
  }
556
730
  }
557
731
 
558
- // --- Backend worker (privileged, app-level JS context) ---
559
- // JSC first. txiki.js opt-in later for web APIs (fetch, WebSocket, timers).
560
-
561
- static JSContext* jsc_backend_ctx = nil;
562
- static dispatch_queue_t jsc_backend_queue = nil;
563
- static BOOL jsc_backend_running = NO;
564
-
565
- bool jsc_backend_create(const char* script_path) {
566
- if (jsc_backend_running) return false;
567
- if (!script_path) return false;
568
- jsc_ensure_init();
569
-
570
- NSString* scriptPath = [NSString stringWithUTF8String:script_path];
571
- jsc_backend_queue = dispatch_queue_create("com.zapp.backend", DISPATCH_QUEUE_SERIAL);
572
-
573
- dispatch_async(jsc_backend_queue, ^{
574
- JSContext* ctx = [[JSContext alloc] initWithVirtualMachine:jsc_vm];
575
- ctx.name = @"Zapp Backend";
576
- ctx.exceptionHandler = ^(JSContext* c, JSValue* exception) {
577
- (void)c;
578
- NSLog(@"[backend ERROR] %@", exception);
579
- };
580
-
581
- // Make inspectable
582
- if (app_get_bootstrap_web_content_inspectable()) {
583
- if ([ctx respondsToSelector:@selector(setInspectable:)]) {
584
- [ctx setInspectable:YES];
585
- }
586
- }
587
-
588
- // Set up standard bridge (console, invokeService, syncWait/Notify)
589
- jsc_setup_bridge(ctx, @"__backend__");
590
-
591
- // Backend-specific host objects
592
- JSValue* bridge = ctx[@"__zappBridge"];
593
-
594
- // quit — terminate the app
595
- bridge[@"quit"] = ^{
596
- dispatch_async(dispatch_get_main_queue(), ^{
597
- exit(0);
598
- });
599
- };
600
-
601
- // subscribeWindowEvent — register backend for window events
602
- bridge[@"subscribeWindowEvent"] = ^(JSValue* windowIdVal, JSValue* eventIdVal) {
603
- int wid = [windowIdVal toInt32];
604
- int eid = [eventIdVal toInt32];
605
- dispatch_async(dispatch_get_main_queue(), ^{
606
- extern void zapp_window_set_backend_listener(int id, int event_id, int has_listener);
607
- if (wid < 0) {
608
- // Subscribe all windows (wid == -1)
609
- for (int i = 0; i < 64; i++) {
610
- zapp_window_set_backend_listener(i, eid, 1);
611
- }
612
- } else {
613
- zapp_window_set_backend_listener(wid, eid, 1);
614
- }
615
- });
616
- };
617
-
618
- // showNotification — fire-and-forget
619
- bridge[@"showNotification"] = ^(NSString* title, NSString* body) {
620
- const char* t = title ? [title UTF8String] : "";
621
- const char* b = body ? [body UTF8String] : "";
622
- // Copy strings for async block
623
- char* tc = strdup(t);
624
- char* bc = strdup(b);
625
- dispatch_async(dispatch_get_main_queue(), ^{
626
- extern void darwin_notification_show_typed(const char*, const char*, const char*, const char*);
627
- darwin_notification_show_typed(tc, "", bc, "default");
628
- free(tc);
629
- free(bc);
630
- });
631
- };
632
-
633
- jsc_backend_ctx = ctx;
634
-
635
- // Load backend bootstrap
636
- extern const char* zapp_backend_bootstrap_script(void);
637
- const char* bootstrap = zapp_backend_bootstrap_script();
638
- if (bootstrap && bootstrap[0] != '\0') {
639
- [ctx evaluateScript:[NSString stringWithUTF8String:bootstrap]];
640
- }
641
-
642
- // Load user backend script — try embedded first, then filesystem.
643
- // scriptPath is the canonical URL form ("/_workers/backend.mjs").
644
- NSString* script = nil;
645
-
646
- extern int zapp_build_use_embedded_assets(void);
647
- extern int zapp_embedded_assets_count;
648
- extern ZappEmbeddedAsset zapp_embedded_assets[];
649
- if (zapp_build_use_embedded_assets() && zapp_embedded_assets_count > 0) {
650
- for (int ai = 0; ai < zapp_embedded_assets_count; ai++) {
651
- NSString* assetPath = [NSString stringWithUTF8String:zapp_embedded_assets[ai].path];
652
- if ([assetPath isEqualToString:scriptPath]) {
653
- if (zapp_embedded_assets[ai].is_brotli && zapp_embedded_assets[ai].uncompressed_len > 0) {
654
- uint8_t* out = malloc(zapp_embedded_assets[ai].uncompressed_len + 1);
655
- size_t decoded = compression_decode_buffer(
656
- out, zapp_embedded_assets[ai].uncompressed_len,
657
- zapp_embedded_assets[ai].data, zapp_embedded_assets[ai].len,
658
- NULL, COMPRESSION_BROTLI);
659
- out[decoded] = '\0';
660
- script = [[NSString alloc] initWithBytesNoCopy:out length:decoded
661
- encoding:NSUTF8StringEncoding freeWhenDone:YES];
662
- } else {
663
- script = [[NSString alloc] initWithBytes:zapp_embedded_assets[ai].data
664
- length:zapp_embedded_assets[ai].len encoding:NSUTF8StringEncoding];
665
- }
666
- NSLog(@"[zapp] backend loaded from embedded: %@", scriptPath);
667
- break;
668
- }
669
- }
670
- }
671
-
672
- if (!script) {
673
- // Dev: Vite plugin writes workers to .zapp/workers/<basename>
674
- char cwd[1024];
675
- NSString* base = getcwd(cwd, sizeof(cwd)) ? [NSString stringWithUTF8String:cwd] : @".";
676
- NSString* basename = [scriptPath lastPathComponent];
677
- NSString* fullPath = [[base stringByAppendingPathComponent:@".zapp/workers"]
678
- stringByAppendingPathComponent:basename];
679
- NSError* err = nil;
680
- script = [NSString stringWithContentsOfFile:fullPath encoding:NSUTF8StringEncoding error:&err];
681
- if (script) NSLog(@"[zapp] backend worker started: %@", fullPath);
682
- else NSLog(@"[zapp] backend script not found: %@", fullPath);
683
- }
684
-
685
- if (script) {
686
- // Async IIFE wrapper enables top-level await — see jsc_worker_create
687
- // for rationale.
688
- NSString* wrapped = [NSString stringWithFormat:
689
- @"(async () => {\n%@\n})().catch(e => { console.error('[backend error]', e && e.stack ? e.stack : e); });",
690
- script];
691
- [ctx evaluateScript:wrapped withSourceURL:[NSURL URLWithString:@"backend.mjs"]];
692
- }
693
- });
694
-
695
- jsc_backend_running = YES;
696
- return true;
697
- }
698
-
699
- void jsc_backend_terminate(void) {
700
- if (!jsc_backend_running) return;
701
- jsc_backend_ctx = nil;
702
- jsc_backend_queue = nil;
703
- jsc_backend_running = NO;
704
- NSLog(@"[zapp] backend worker terminated");
705
- }
706
-
707
- void jsc_backend_eval_js(const char* js) {
708
- if (!jsc_backend_running || !jsc_backend_ctx || !jsc_backend_queue || !js) return;
732
+ // Broadcast a JS snippet to every active worker (headless + webview-owned).
733
+ // Used by native event dispatch to deliver app/window events to every worker
734
+ // that may have subscribed. Each worker evaluates on its own serial queue.
735
+ void jsc_broadcast_eval_js(const char* js) {
736
+ if (!js) return;
709
737
  NSString* script = [NSString stringWithUTF8String:js];
710
- dispatch_async(jsc_backend_queue, ^{
711
- if (jsc_backend_ctx) {
712
- [jsc_backend_ctx evaluateScript:script];
713
- }
714
- });
738
+ for (int i = 0; i < JSC_MAX_WORKERS; i++) {
739
+ if (!jsc_workers[i].active) continue;
740
+ NSString* wid = [NSString stringWithUTF8String:jsc_workers[i].worker_id];
741
+ dispatch_queue_t queue = jsc_queues[wid];
742
+ JSContext* ctx = jsc_contexts[wid];
743
+ if (!queue || !ctx) continue;
744
+ dispatch_async(queue, ^{
745
+ [ctx evaluateScript:script];
746
+ });
747
+ }
715
748
  }
716
749
 
717
- bool jsc_backend_is_running(void) {
718
- return jsc_backend_running;
719
- }
@@ -945,13 +945,9 @@ static void* txiki_backend_thread(void* arg) {
945
945
  JS_FreeValue(ctx, bridge);
946
946
  JS_FreeValue(ctx, global);
947
947
 
948
- // Load backend bootstrap
949
- extern const char* zapp_backend_bootstrap_script(void);
950
- const char* bootstrap = zapp_backend_bootstrap_script();
951
- if (bootstrap && bootstrap[0] != '\0') {
952
- JSValue r = JS_Eval(ctx, bootstrap, strlen(bootstrap), "<backend-bootstrap>", JS_EVAL_TYPE_GLOBAL);
953
- JS_FreeValue(ctx, r);
954
- }
948
+ // Note: dead code path — app_start_backend no longer dispatches here.
949
+ // Backend bootstrap was merged into the worker bootstrap; this function
950
+ // is left in place until the full txiki backend removal lands.
955
951
 
956
952
  // Load user backend script — try embedded first, then filesystem.
957
953
  // slot->script_url is the canonical URL form ("/_workers/backend.mjs").
@@ -1085,3 +1081,14 @@ void txiki_backend_eval_js(const char* js) {
1085
1081
  bool txiki_backend_is_running(void) {
1086
1082
  return txiki_backend_running != 0;
1087
1083
  }
1084
+
1085
+ // Broadcast a JS snippet to every active worker. Counterpart of
1086
+ // jsc_broadcast_eval_js — used by native event dispatch to deliver app and
1087
+ // window events to every worker. TODO: txiki uses a message-queue/libuv
1088
+ // dispatch pattern rather than direct eval; a full implementation requires
1089
+ // adding an "eval this JS" message type handled in on_async_message. For now
1090
+ // this is a stub so the JSC engine path works; txiki headless workers will
1091
+ // not receive forwarded events until this is wired up.
1092
+ void txiki_broadcast_eval_js(const char* js) {
1093
+ (void)js;
1094
+ }
package/package.json CHANGED
@@ -1,22 +1,26 @@
1
1
  {
2
2
  "name": "@zappdev/cli",
3
- "version": "0.5.0-alpha.9",
3
+ "version": "0.6.0-alpha.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zapp": "./src/zapp-cli.ts"
7
7
  },
8
8
  "exports": {
9
- "./config": "./src/config.ts"
9
+ "./config": {
10
+ "types": "./src/config.ts",
11
+ "default": "./dist/config.js"
12
+ }
10
13
  },
11
14
  "files": [
12
15
  "src/",
16
+ "dist/",
13
17
  "native/",
14
18
  "bootstrap/",
15
19
  "assets/",
16
20
  "vendor/webview2/"
17
21
  ],
18
22
  "scripts": {
19
- "prepack": "cp -r ../native ./native && cp -r ../bootstrap ./bootstrap && cp -r ../assets ./assets && mkdir -p ./vendor && cp -r ../vendor/webview2 ./vendor/webview2",
23
+ "prepack": "bun build src/config.ts --outdir dist --format esm --target node && cp -r ../native ./native && cp -r ../bootstrap ./bootstrap && cp -r ../assets ./assets && mkdir -p ./vendor && cp -r ../vendor/webview2 ./vendor/webview2",
20
24
  "postpack": "rm -rf ./native ./bootstrap ./assets ./vendor"
21
25
  },
22
26
  "dependencies": {
@@ -52,6 +52,35 @@ fn zapp_build_accept_first_mouse() -> int { return ${config.macos?.acceptFirstMo
52
52
  return outPath;
53
53
  }
54
54
 
55
+ // Generate .zapp/zapp_headless_workers.zc — the function native/app/app.zc
56
+ // declares as `extern fn zapp_start_headless_workers()`. Body contains one
57
+ // call per entry in zappConfig.headless; empty body when no headless workers
58
+ // are configured.
59
+ export async function generateHeadlessWorkers(opts: { root: string; headless?: Record<string, string> }): Promise<string> {
60
+ const { root, headless } = opts;
61
+ const zappDir = path.join(root, ".zapp");
62
+ await mkdir(zappDir, { recursive: true });
63
+
64
+ const entries = Object.entries(headless ?? {});
65
+ const calls = entries
66
+ .map(([id]) => ` zapp_start_headless_worker("h-${id}", "/_workers/_headless_${id}.mjs");`)
67
+ .join("\n");
68
+
69
+ const body = calls || ` // No headless workers configured.`;
70
+
71
+ const content = `// AUTO-GENERATED by zapp CLI. Do not edit.
72
+ // Implementation for native/app/app.zc's extern fn zapp_start_headless_workers().
73
+
74
+ fn zapp_start_headless_workers() -> void {
75
+ ${body}
76
+ }
77
+ `;
78
+
79
+ const outPath = path.join(zappDir, "zapp_headless_workers.zc");
80
+ await Bun.write(outPath, content);
81
+ return outPath;
82
+ }
83
+
55
84
  // Generate .zapp/zapp_platform.zc with .m file cflags
56
85
  export async function generatePlatformConfig(root: string, buildFile?: string): Promise<string> {
57
86
  const zappDir = path.join(root, ".zapp");
package/src/config.ts CHANGED
@@ -26,7 +26,12 @@ export interface ZappConfig {
26
26
  version?: string;
27
27
  assetDir?: string; // Default: "./dist" (Vite), configurable for static sites
28
28
  devPort?: number; // Default: 5173
29
- backend?: string; // Path to backend worker source (e.g. "src/server.ts"). Omit for no backend.
29
+ /**
30
+ * Headless workers to start at app boot, keyed by ID.
31
+ * Example: `{ db: "src/workers/db.ts", sync: "src/workers/sync.ts" }`
32
+ * IDs are used for termination via `Workers.terminate(id)`.
33
+ */
34
+ headless?: Record<string, string>;
30
35
  deepLinkSchemes?: string[]; // e.g. ["myapp"] → registers myapp:// URL scheme
31
36
  macos?: MacOSConfig;
32
37
  security?: SecurityConfig;
package/src/init.ts CHANGED
@@ -56,7 +56,6 @@ fn run_app() -> int {
56
56
  webContentInspectable: Zapp::inspectable_auto(),
57
57
  maxWorkers: 0,
58
58
  qjsStackSize: 0,
59
- backend: false,
60
59
  };
61
60
  let app = App::new(config);
62
61
  app.service.add("greet", greet);
@@ -110,6 +109,12 @@ export default defineConfig({
110
109
  name: "${name}",
111
110
  identifier: "${identifier}",
112
111
  version: "0.1.0",
112
+ // Add headless TypeScript workers that start when the app boots.
113
+ // Keys are worker IDs (used for termination); values are source paths.
114
+ //
115
+ // headless: {
116
+ // db: "src/workers/db.ts",
117
+ // },
113
118
  });
114
119
  `);
115
120
 
@@ -127,12 +132,12 @@ export default defineConfig({
127
132
 
128
133
  pkgObj.dependencies = {
129
134
  ...(pkgObj.dependencies ?? {}),
130
- "@zappdev/runtime": "^0.5.0-alpha.0",
135
+ "@zappdev/runtime": "^0.6.0-alpha.0",
131
136
  };
132
137
  pkgObj.devDependencies = {
133
138
  ...(pkgObj.devDependencies ?? {}),
134
- "@zappdev/cli": "^0.5.0-alpha.0",
135
- "@zappdev/vite": "^0.5.0-alpha.0",
139
+ "@zappdev/cli": "^0.6.0-alpha.0",
140
+ "@zappdev/vite": "^0.6.0-alpha.0",
136
141
  };
137
142
 
138
143
  await Bun.write(pkgPath, JSON.stringify(pkgObj, null, 2));
@@ -171,7 +176,7 @@ export default defineConfig({
171
176
  if (!viteConfig.includes("zappWorkers(")) {
172
177
  viteConfig = viteConfig.replace(
173
178
  /plugins:\s*\[/,
174
- "plugins: [zappWorkers({ backend: zappConfig.backend }), "
179
+ "plugins: [zappWorkers({ headless: zappConfig.headless }), "
175
180
  );
176
181
  }
177
182
 
@@ -183,7 +188,7 @@ import { zappWorkers } from "@zappdev/vite";
183
188
  import zappConfig from "./zapp.config";
184
189
 
185
190
  export default defineConfig({
186
- plugins: [zappWorkers({ backend: zappConfig.backend })],
191
+ plugins: [zappWorkers({ headless: zappConfig.headless })],
187
192
  });
188
193
  `);
189
194
  }
package/src/native.ts CHANGED
@@ -91,13 +91,14 @@ interface CompileOptions {
91
91
  buildConfigFile: string; // .zapp/zapp_build_config.zc
92
92
  bootstrapFile?: string; // .zapp/zapp_bootstrap.zc
93
93
  assetsFile?: string; // .zapp/zapp_assets.zc (embedded brotli assets)
94
+ headlessFile?: string; // .zapp/zapp_headless_workers.zc
94
95
  output: string; // Binary output path
95
96
  nativeDir: string; // Framework source dir
96
97
  optimize: boolean; // Size optimizations
97
98
  }
98
99
 
99
100
  export async function compileNative(opts: CompileOptions): Promise<void> {
100
- const { root, buildFile, buildConfigFile, bootstrapFile, assetsFile, output, nativeDir, optimize } = opts;
101
+ const { root, buildFile, buildConfigFile, bootstrapFile, assetsFile, headlessFile, output, nativeDir, optimize } = opts;
101
102
 
102
103
  // Generate platform config with .m file paths
103
104
  const { generatePlatformConfig } = await import("./build-config");
@@ -112,6 +113,7 @@ export async function compileNative(opts: CompileOptions): Promise<void> {
112
113
  platformFile,
113
114
  ...(bootstrapFile ? [bootstrapFile] : []),
114
115
  ...(assetsFile ? [assetsFile] : []),
116
+ ...(headlessFile ? [headlessFile] : []),
115
117
  "-I", nativeDir,
116
118
  "-o", output,
117
119
  // Suppress C compiler warnings by default. The framework and zc stdlib
package/src/zapp-cli.ts CHANGED
@@ -4,7 +4,7 @@ import process from "node:process";
4
4
  import { mkdir } from "node:fs/promises";
5
5
  import { existsSync } from "node:fs";
6
6
  import { loadConfig } from "./config";
7
- import { generateBuildConfig, generatePlatformConfig } from "./build-config";
7
+ import { generateBuildConfig, generatePlatformConfig, generateHeadlessWorkers } from "./build-config";
8
8
  import { generateBindings } from "./generate";
9
9
  import { compileNative, ensureTxikiBuilt, hasTxikiEnabled, hasAnyWorkerEngine } from "./native";
10
10
  import { resolveNativeDir, resolveBootstrapDir } from "./paths";
@@ -71,6 +71,7 @@ async function runDev(root: string) {
71
71
  // 3. Generate build config + bootstrap (dev mode)
72
72
  const buildConfigFile = await generateBuildConfig({ root, config, mode: "dev", devUrl });
73
73
  const platformFile = await generatePlatformConfig(root);
74
+ const headlessFile = await generateHeadlessWorkers({ root, headless: config.headless });
74
75
  const zappDir = path.join(root, ".zapp");
75
76
  process.stdout.write("[zapp] generating bootstrap...\n");
76
77
  const bootstrapFile = await generateBootstrap(zappDir);
@@ -137,6 +138,7 @@ async function runDev(root: string) {
137
138
  buildConfigFile,
138
139
  bootstrapFile,
139
140
  assetsFile,
141
+ headlessFile,
140
142
  output: nativeOut,
141
143
  nativeDir,
142
144
  optimize: false,
@@ -260,6 +262,7 @@ async function runBuild(root: string) {
260
262
  // 5. Generate build config + bootstrap (prod mode, embedded assets)
261
263
  const buildConfigFile = await generateBuildConfig({ root, config, mode: "prod", embedAssets: true });
262
264
  const platformFile = await generatePlatformConfig(root);
265
+ const headlessFile = await generateHeadlessWorkers({ root, headless: config.headless });
263
266
  const bootstrapFile = await generateBootstrap(zappDir);
264
267
 
265
268
  // 5. Compile native binary (assets embedded in binary)
@@ -276,6 +279,7 @@ async function runBuild(root: string) {
276
279
  buildConfigFile,
277
280
  bootstrapFile,
278
281
  assetsFile,
282
+ headlessFile,
279
283
  output: nativeOut,
280
284
  nativeDir,
281
285
  optimize: true,
@@ -1,139 +0,0 @@
1
- /**
2
- * Backend worker bootstrap — injected into the privileged backend JS context.
3
- *
4
- * Sets up event dispatch and the `Zapp` global for user-friendly API.
5
- * Host objects available on __zappBridge:
6
- * - invokeService(method, args) → JSValue
7
- * - syncWait(key, timeoutMs), syncNotify(key, count)
8
- * - showNotification(title, body)
9
- * - createWindow(opts)
10
- * - quit()
11
- *
12
- * JSC only for now. txiki.js opt-in later for web APIs (fetch, WebSocket, timers).
13
- */
14
-
15
- (function () {
16
- const bridge = (self as any).__zappBridge;
17
- if (!bridge) return;
18
-
19
- const listeners: Record<string, Array<(data: unknown) => void>> = {};
20
-
21
- // Event registration
22
- bridge.on = function (event: string, handler: (data: unknown) => void) {
23
- if (!listeners[event]) listeners[event] = [];
24
- listeners[event].push(handler);
25
- return () => {
26
- listeners[event] = (listeners[event] || []).filter((h) => h !== handler);
27
- };
28
- };
29
-
30
- // Called by native when app events fire (via jsc_backend_eval_js)
31
- bridge._dispatchAppEvent = function (eventId: number, dataJson: string) {
32
- const eventMap: Record<number, string> = {
33
- 100: "app:started",
34
- 101: "app:shutdown",
35
- 102: "app:notification-click",
36
- 103: "app:notification-action",
37
- 104: "app:reopen",
38
- 105: "app:open-url",
39
- 106: "app:active",
40
- 107: "app:inactive",
41
- };
42
- const name = eventMap[eventId];
43
- if (!name) return;
44
- let data: unknown = dataJson;
45
- try {
46
- data = JSON.parse(dataJson);
47
- } catch {}
48
- const handlers = listeners[name] || [];
49
- for (let i = 0; i < handlers.length; i++) {
50
- try {
51
- handlers[i](data);
52
- } catch (e) {
53
- console.error("[backend]", e);
54
- }
55
- }
56
-
57
- // Also dispatch notification events under __notif: names
58
- // so Notification.on("click"/"action") from @zappdev/runtime works
59
- if (eventId === 102) {
60
- const notifHandlers = listeners["__notif:click"] || [];
61
- for (const h of notifHandlers) try { h(data); } catch (e) { console.error("[backend]", e); }
62
- } else if (eventId === 103) {
63
- const notifHandlers = listeners["__notif:action"] || [];
64
- for (const h of notifHandlers) try { h(data); } catch (e) { console.error("[backend]", e); }
65
- }
66
- };
67
-
68
- // Window event name → event ID mapping (for backend subscription)
69
- const windowEventIds: Record<string, number> = {
70
- "window:ready": 0, "window:focus": 1, "window:blur": 2,
71
- "window:resize": 3, "window:move": 4, "window:close": 5,
72
- "window:minimize": 6, "window:maximize": 7, "window:restore": 8,
73
- "window:fullscreen": 9, "window:unfullscreen": 10,
74
- };
75
-
76
- // Expose as Symbol.for('zapp.bridge') so @zappdev/runtime works in the backend.
77
- // The runtime's getBridge() looks for this symbol on globalThis.
78
- const runtimeBridge = {
79
- on(name: string, handler: (data: unknown) => void) {
80
- const off = bridge.on(name, handler);
81
-
82
- // If subscribing to a window event, tell native to forward to backend
83
- const eventId = windowEventIds[name];
84
- if (eventId !== undefined && bridge.subscribeWindowEvent) {
85
- // Subscribe all windows (pass -1 for "all")
86
- bridge.subscribeWindowEvent(-1, eventId);
87
- }
88
-
89
- return off;
90
- },
91
- emit(name: string, payload?: Record<string, unknown>) {
92
- // Broadcast to every webview via the native dispatchEventToAll bridge.
93
- // Each webview's bridge._onEvent picks it up and fans out to listeners
94
- // registered with Events.on(name, ...).
95
- if (typeof bridge.dispatchEventToAll === "function") {
96
- bridge.dispatchEventToAll(name, payload ?? {});
97
- }
98
- },
99
- invoke(method: string, args?: Record<string, unknown>) {
100
- // Use host object for sync service invocation
101
- const result = bridge.invokeService(method, args);
102
- return Promise.resolve(result);
103
- },
104
- post(msg: string) {
105
- // No WebView to post to — no-op in backend
106
- },
107
- // Sync primitives — forward to the native host objects so Sync.wait /
108
- // Sync.notify work from the backend the same way they work in workers.
109
- syncWait(key: string, timeoutMs?: number | null) {
110
- if (typeof bridge.syncWait !== "function") {
111
- throw new Error("Sync bridge is unavailable.");
112
- }
113
- return bridge.syncWait(key, timeoutMs ?? -1);
114
- },
115
- syncNotify(key: string, count?: number) {
116
- if (typeof bridge.syncNotify !== "function") return;
117
- bridge.syncNotify(key, count ?? 1);
118
- },
119
- // Called by native Layer 3 when dispatching window events to backend
120
- _onEvent(name: string, payload: string) {
121
- const handlers = listeners[name] || [];
122
- let parsed: unknown = payload;
123
- try { parsed = JSON.parse(payload); } catch {}
124
- for (let i = 0; i < handlers.length; i++) {
125
- try { handlers[i](parsed); } catch (e) { console.error("[backend]", e); }
126
- }
127
- },
128
- };
129
- (globalThis as any)[Symbol.for("zapp.bridge")] = runtimeBridge;
130
-
131
- // Convenience global — the user-facing API (legacy, still works)
132
- (self as any).Zapp = {
133
- on: bridge.on,
134
- invokeService: bridge.invokeService,
135
- createWindow: bridge.createWindow,
136
- showNotification: bridge.showNotification,
137
- quit: bridge.quit,
138
- };
139
- })();