@dimina-kit/electron-deck 0.1.0-dev.20260610082053

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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/dist/client/index.d.ts +20 -0
  4. package/dist/client/index.d.ts.map +1 -0
  5. package/dist/client/index.js +310 -0
  6. package/dist/client/index.js.map +1 -0
  7. package/dist/client/layout-client.d.ts +51 -0
  8. package/dist/client/layout-client.d.ts.map +1 -0
  9. package/dist/electron-deck-DfFKeFEC.js +2295 -0
  10. package/dist/electron-deck-DfFKeFEC.js.map +1 -0
  11. package/dist/electron-deck.d.ts +57 -0
  12. package/dist/electron-deck.d.ts.map +1 -0
  13. package/dist/errors-NE2ig7L5.js +53 -0
  14. package/dist/errors-NE2ig7L5.js.map +1 -0
  15. package/dist/errors.d.ts +33 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/events.d.ts +14 -0
  18. package/dist/events.d.ts.map +1 -0
  19. package/dist/host/capability.d.ts +50 -0
  20. package/dist/host/capability.d.ts.map +1 -0
  21. package/dist/host/control-bus.d.ts +92 -0
  22. package/dist/host/control-bus.d.ts.map +1 -0
  23. package/dist/host/index.d.ts +42 -0
  24. package/dist/host/index.d.ts.map +1 -0
  25. package/dist/host/index.js +2 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +3 -0
  29. package/dist/internal/deck-app.d.ts +415 -0
  30. package/dist/internal/deck-app.d.ts.map +1 -0
  31. package/dist/internal/electron-types.d.ts +131 -0
  32. package/dist/internal/electron-types.d.ts.map +1 -0
  33. package/dist/internal/event-bus.d.ts +29 -0
  34. package/dist/internal/event-bus.d.ts.map +1 -0
  35. package/dist/internal/ipc-registry-memory.d.ts +25 -0
  36. package/dist/internal/ipc-registry-memory.d.ts.map +1 -0
  37. package/dist/internal/lifecycle-manager.d.ts +28 -0
  38. package/dist/internal/lifecycle-manager.d.ts.map +1 -0
  39. package/dist/internal/resource-registry.d.ts +9 -0
  40. package/dist/internal/resource-registry.d.ts.map +1 -0
  41. package/dist/internal/trust-set.d.ts +53 -0
  42. package/dist/internal/trust-set.d.ts.map +1 -0
  43. package/dist/internal/wire-transport.d.ts +159 -0
  44. package/dist/internal/wire-transport.d.ts.map +1 -0
  45. package/dist/main/compositor.d.ts +123 -0
  46. package/dist/main/compositor.d.ts.map +1 -0
  47. package/dist/main/connection.d.ts +37 -0
  48. package/dist/main/connection.d.ts.map +1 -0
  49. package/dist/main/debug-tap.d.ts +55 -0
  50. package/dist/main/debug-tap.d.ts.map +1 -0
  51. package/dist/main/disposable.d.ts +18 -0
  52. package/dist/main/disposable.d.ts.map +1 -0
  53. package/dist/main/index.d.ts +16 -0
  54. package/dist/main/index.d.ts.map +1 -0
  55. package/dist/main/index.js +152 -0
  56. package/dist/main/index.js.map +1 -0
  57. package/dist/main/logger.d.ts +15 -0
  58. package/dist/main/logger.d.ts.map +1 -0
  59. package/dist/main/scope.d.ts +52 -0
  60. package/dist/main/scope.d.ts.map +1 -0
  61. package/dist/main/view-handle.d.ts +131 -0
  62. package/dist/main/view-handle.d.ts.map +1 -0
  63. package/dist/preload/index.cjs +105 -0
  64. package/dist/preload/index.cjs.map +1 -0
  65. package/dist/preload/index.d.ts +43 -0
  66. package/dist/preload/index.d.ts.map +1 -0
  67. package/dist/preload/index.js +80 -0
  68. package/dist/preload/index.js.map +1 -0
  69. package/dist/protocol-BJqo-vP2.js +23 -0
  70. package/dist/protocol-BJqo-vP2.js.map +1 -0
  71. package/dist/shared/protocol.d.ts +76 -0
  72. package/dist/shared/protocol.d.ts.map +1 -0
  73. package/dist/types.d.ts +504 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/view-handle-CR-yWNfr.js +701 -0
  76. package/dist/view-handle-CR-yWNfr.js.map +1 -0
  77. package/package.json +86 -0
@@ -0,0 +1,2295 @@
1
+ import { n as DeckRemoteError, r as EventNotBoundError } from "./errors-NE2ig7L5.js";
2
+ import { i as DeckChannel, t as BRIDGE_PROTOCOL_VERSION } from "./protocol-BJqo-vP2.js";
3
+ import { i as createScope, r as createCompositor, t as createViewHandle } from "./view-handle-CR-yWNfr.js";
4
+ import { randomUUID } from "node:crypto";
5
+ //#region src/events.ts
6
+ var internals = /* @__PURE__ */ new WeakMap();
7
+ var HostEventImpl = class {
8
+ name;
9
+ constructor(name) {
10
+ this.name = name;
11
+ internals.set(this, {
12
+ publisher: null,
13
+ listeners: /* @__PURE__ */ new Set()
14
+ });
15
+ }
16
+ publish(payload) {
17
+ const it = internals.get(this);
18
+ if (!it || it.publisher === null) throw new EventNotBoundError(this.name);
19
+ it.publisher(payload);
20
+ for (const l of it.listeners) try {
21
+ l(payload);
22
+ } catch (e) {
23
+ console.error(`[electron-deck] HostEvent "${this.name}" listener threw:`, e);
24
+ }
25
+ }
26
+ on(listener) {
27
+ const it = internals.get(this);
28
+ if (!it) throw new TypeError(`HostEvent "${this.name}" has no internals — was it produced by defineEvent()?`);
29
+ it.listeners.add(listener);
30
+ return { dispose: () => {
31
+ it.listeners.delete(listener);
32
+ } };
33
+ }
34
+ };
35
+ function defineEvent(name) {
36
+ if (typeof name !== "string" || name.length === 0) throw new TypeError("defineEvent(name): name must be a non-empty string");
37
+ return new HostEventImpl(name);
38
+ }
39
+ /**
40
+ * 校验 `ev` 是否真的来自 `defineEvent()`。判定方式是 WeakMap 身份,而非
41
+ * `instanceof` —— 这样跨 vm.Context(同 module instance)也能工作。
42
+ *
43
+ * @internal
44
+ */
45
+ function isHostEvent(ev) {
46
+ if (ev === null || typeof ev !== "object") return false;
47
+ return internals.has(ev);
48
+ }
49
+ /** @internal framework 在 Bind 阶段调;同实例重复 bind 覆盖前任 */
50
+ function bindHostEvent(event, publisher) {
51
+ const it = internals.get(event);
52
+ if (!it) throw new TypeError("bindHostEvent: argument is not a HostEvent produced by defineEvent()");
53
+ it.publisher = publisher;
54
+ }
55
+ /** @internal framework 在 Cleanup 阶段调 */
56
+ function unbindHostEvent(event) {
57
+ const it = internals.get(event);
58
+ if (it) {
59
+ it.publisher = null;
60
+ it.listeners.clear();
61
+ }
62
+ }
63
+ //#endregion
64
+ //#region src/internal/event-bus.ts
65
+ /**
66
+ * Framework-internal event bus。
67
+ *
68
+ * 责任:
69
+ * - `bindDeclaredEvents(events)`:在 Bind 阶段调;对每个声明的 HostEvent,调用
70
+ * framework binder,让该 event 的 `publish()` 走 bus。
71
+ * - `publish(name, payload)`:通知 `subscribe(name, ...)` 注册的所有 listener。
72
+ * - `subscribe(name, listener)`:返回 `Disposable`,dispose 后停止通知。
73
+ * - `unbindAll()`:解绑所有 declared event publisher + 清空 subscribers。
74
+ *
75
+ * @internal
76
+ */
77
+ var EventBus = class {
78
+ subscribers = /* @__PURE__ */ new Map();
79
+ allSubscribers = /* @__PURE__ */ new Set();
80
+ inFlight = /* @__PURE__ */ new Set();
81
+ boundEvents = [];
82
+ bindDeclaredEvents(events) {
83
+ for (const ev of events) {
84
+ bindHostEvent(ev, (payload) => this.publish(ev.name, payload));
85
+ this.boundEvents.push(ev);
86
+ }
87
+ }
88
+ publish(name, payload) {
89
+ if (this.inFlight.has(name)) {
90
+ console.error(`[electron-deck] reentrant publish on "${name}" dropped (listener republished the same event synchronously)`);
91
+ return;
92
+ }
93
+ this.inFlight.add(name);
94
+ try {
95
+ const subs = this.subscribers.get(name);
96
+ if (subs && subs.size > 0) {
97
+ const snapshot = Array.from(subs);
98
+ for (const l of snapshot) try {
99
+ l(payload);
100
+ } catch (e) {
101
+ console.error(`[electron-deck] subscriber for "${name}" threw:`, e);
102
+ }
103
+ }
104
+ if (this.allSubscribers.size > 0) {
105
+ const snapshot = Array.from(this.allSubscribers);
106
+ for (const l of snapshot) try {
107
+ l(name, payload);
108
+ } catch (e) {
109
+ console.error(`[electron-deck] catch-all subscriber threw on "${name}":`, e);
110
+ }
111
+ }
112
+ } finally {
113
+ this.inFlight.delete(name);
114
+ }
115
+ }
116
+ subscribe(name, listener) {
117
+ let subs = this.subscribers.get(name);
118
+ if (!subs) {
119
+ subs = /* @__PURE__ */ new Set();
120
+ this.subscribers.set(name, subs);
121
+ }
122
+ subs.add(listener);
123
+ const set = subs;
124
+ return { dispose: () => {
125
+ set.delete(listener);
126
+ } };
127
+ }
128
+ /**
129
+ * 订阅所有 channel 的 publish —— framework wire-transport 把 declared
130
+ * HostEvent.publish() 桥到 webContents.send 时用。
131
+ */
132
+ subscribeAll(listener) {
133
+ this.allSubscribers.add(listener);
134
+ return { dispose: () => {
135
+ this.allSubscribers.delete(listener);
136
+ } };
137
+ }
138
+ unbindAll() {
139
+ for (const ev of this.boundEvents) unbindHostEvent(ev);
140
+ this.boundEvents = [];
141
+ this.subscribers.clear();
142
+ this.allSubscribers.clear();
143
+ }
144
+ };
145
+ //#endregion
146
+ //#region src/internal/ipc-registry-memory.ts
147
+ /**
148
+ * In-memory `TypedIpcRegistry` impl —— Phase 2 不接真 Electron `ipcMain`,提
149
+ * 供 main-process 同进程 `handle` / `on` / `send` / `invoke` 的 fake。
150
+ *
151
+ * - `handle` / `invoke` 与 `on` / `send` 是**独立 channel space**:send 只触
152
+ * 发 on listener;invoke 只调 handle handler;同 channel 名互不影响。
153
+ * - audience / validator option Phase 2 不强制,仅 accept;Phase 3 接 trusted
154
+ * sender set 时再实施。
155
+ *
156
+ * @internal
157
+ */
158
+ var InMemoryTypedIpcRegistry = class {
159
+ handlers = /* @__PURE__ */ new Map();
160
+ listeners = /* @__PURE__ */ new Map();
161
+ handle(channel, handler, _options) {
162
+ if (this.handlers.has(channel)) throw new Error(`channel already handled: "${channel}"`);
163
+ const cast = handler;
164
+ this.handlers.set(channel, cast);
165
+ return { dispose: () => {
166
+ if (this.handlers.get(channel) === cast) this.handlers.delete(channel);
167
+ } };
168
+ }
169
+ on(channel, listener) {
170
+ let set = this.listeners.get(channel);
171
+ if (!set) {
172
+ set = /* @__PURE__ */ new Set();
173
+ this.listeners.set(channel, set);
174
+ }
175
+ const cast = listener;
176
+ set.add(cast);
177
+ const ref = set;
178
+ return { dispose: () => {
179
+ ref.delete(cast);
180
+ } };
181
+ }
182
+ send(_target, channel, payload) {
183
+ const set = this.listeners.get(channel);
184
+ if (!set) return;
185
+ const snapshot = Array.from(set);
186
+ for (const l of snapshot) try {
187
+ l(payload);
188
+ } catch (e) {
189
+ console.error(`[electron-deck] ipc on "${channel}" listener threw:`, e);
190
+ }
191
+ }
192
+ /** @internal main-internal helper used by Phase 2 setup tests / framework */
193
+ async invoke(channel, ...args) {
194
+ const handler = this.handlers.get(channel);
195
+ if (!handler) throw new Error(`no handler for channel: "${channel}"`);
196
+ return await handler(...args);
197
+ }
198
+ };
199
+ //#endregion
200
+ //#region src/internal/trust-set.ts
201
+ function createTrustSet() {
202
+ const refs = /* @__PURE__ */ new Map();
203
+ return {
204
+ admit(wc, owner) {
205
+ const cur = refs.get(wc) ?? 0;
206
+ refs.set(wc, cur + 1);
207
+ let disposed = false;
208
+ const lease = owner.own({ dispose: () => {
209
+ if (disposed) return;
210
+ disposed = true;
211
+ const c = refs.get(wc);
212
+ if (c === void 0) return;
213
+ if (c <= 1) refs.delete(wc);
214
+ else refs.set(wc, c - 1);
215
+ } });
216
+ let released = false;
217
+ return { dispose: () => {
218
+ if (released) return;
219
+ released = true;
220
+ lease.dispose();
221
+ } };
222
+ },
223
+ isTrusted(id) {
224
+ for (const wc of refs.keys()) if (wc.id === id) return true;
225
+ return false;
226
+ },
227
+ snapshot() {
228
+ return Array.from(refs.keys());
229
+ }
230
+ };
231
+ }
232
+ //#endregion
233
+ //#region src/internal/lifecycle-manager.ts
234
+ var ORDER = [
235
+ "init",
236
+ "bind",
237
+ "setup",
238
+ "ready",
239
+ "drain",
240
+ "cleanup",
241
+ "destroy",
242
+ "quit"
243
+ ];
244
+ var LifecyclePhaseError = class extends Error {
245
+ from;
246
+ to;
247
+ constructor(from, to) {
248
+ super(`Illegal lifecycle transition: ${from} → ${to}`);
249
+ this.name = "LifecyclePhaseError";
250
+ this.from = from;
251
+ this.to = to;
252
+ }
253
+ };
254
+ function phaseOrder(p) {
255
+ const idx = ORDER.indexOf(p);
256
+ if (idx === -1) throw new Error(`unknown lifecycle phase: ${String(p)}`);
257
+ return idx;
258
+ }
259
+ var LifecycleManager = class {
260
+ current = "init";
261
+ enter(to) {
262
+ const fromOrder = phaseOrder(this.current);
263
+ if (phaseOrder(to) !== fromOrder + 1) throw new LifecyclePhaseError(this.current, to);
264
+ this.current = to;
265
+ }
266
+ assertAtLeast(p) {
267
+ if (phaseOrder(this.current) < phaseOrder(p)) throw new Error(`lifecycle assertion failed: current=${this.current}, required >= ${p}`);
268
+ }
269
+ /**
270
+ * Abnormal-path setter:setup throw / 紧急 shutdown 跳过中间 phase 直达
271
+ * 指定 teardown phase。不做顺序校验,仅 framework 内部使用。
272
+ *
273
+ * @internal
274
+ */
275
+ _force(to) {
276
+ this.current = to;
277
+ }
278
+ };
279
+ //#endregion
280
+ //#region src/internal/resource-registry.ts
281
+ var ResourceRegistryImpl = class {
282
+ entries = [];
283
+ nextId = 1;
284
+ allDisposed = false;
285
+ add(d) {
286
+ const underlying = typeof d === "function" ? d : () => d.dispose();
287
+ if (this.allDisposed) {
288
+ runQuietly(underlying);
289
+ return { dispose: () => {} };
290
+ }
291
+ const id = this.nextId++;
292
+ const entry = {
293
+ id,
294
+ dispose: underlying
295
+ };
296
+ this.entries.push(entry);
297
+ let disposedThis = false;
298
+ return { dispose: async () => {
299
+ if (disposedThis) return;
300
+ disposedThis = true;
301
+ const idx = this.entries.findIndex((e) => e.id === id);
302
+ if (idx !== -1) this.entries.splice(idx, 1);
303
+ await underlying();
304
+ } };
305
+ }
306
+ async disposeAll() {
307
+ if (this.allDisposed) return;
308
+ this.allDisposed = true;
309
+ const toDispose = this.entries.slice().reverse();
310
+ this.entries = [];
311
+ const errors = [];
312
+ for (const entry of toDispose) try {
313
+ await entry.dispose();
314
+ } catch (e) {
315
+ errors.push(e);
316
+ }
317
+ if (errors.length > 0) throw new AggregateError(errors, `ResourceRegistry.disposeAll: ${errors.length} dispose(s) failed`);
318
+ }
319
+ };
320
+ function runQuietly(fn) {
321
+ try {
322
+ const r = fn();
323
+ if (r && typeof r.then === "function") return r.catch(() => {});
324
+ return;
325
+ } catch {
326
+ return;
327
+ }
328
+ }
329
+ //#endregion
330
+ //#region src/host/capability.ts
331
+ function createCapabilityRegistry() {
332
+ const entries = /* @__PURE__ */ new Set();
333
+ return {
334
+ policy: { allows(senderId, name) {
335
+ for (const e of entries) if (e.grant.senderId === senderId && e.grant.commands.has(name)) return true;
336
+ return false;
337
+ } },
338
+ issue(grant) {
339
+ let revoked = false;
340
+ const off1 = grant.senderScope.on("reset", () => revoke());
341
+ const off2 = grant.senderScope.on("closed", () => revoke());
342
+ function revoke() {
343
+ if (revoked) return;
344
+ revoked = true;
345
+ entries.delete(entry);
346
+ off1.dispose();
347
+ off2.dispose();
348
+ }
349
+ const entry = {
350
+ grant,
351
+ revoke
352
+ };
353
+ entries.add(entry);
354
+ return { dispose: revoke };
355
+ },
356
+ revokeBySenderId(senderId) {
357
+ const matching = [];
358
+ for (const e of entries) if (e.grant.senderId === senderId) matching.push(e);
359
+ for (const e of matching) e.revoke();
360
+ }
361
+ };
362
+ }
363
+ //#endregion
364
+ //#region src/internal/wire-transport.ts
365
+ /**
366
+ * WireTransport — main 端的真 Electron wire 桥接(Phase 3b)。
367
+ *
368
+ * 责任:
369
+ * - 在 `ipcMain.handle('__electron-deck:invoke')` / `'__electron-deck:probe'` 上路由
370
+ * webview → main 的 RPC 请求,按 `senderPolicy` gating + kind 派发到 host /
371
+ * simulator handler,序列化成 `InvokeResponse` 帧。
372
+ * - 订阅 framework `EventBus`,把 declared `HostEvent.publish(payload)` 通过
373
+ * `webContents.send('__electron-deck:event', envelope)` 推送给所有 trusted
374
+ * webContents。
375
+ *
376
+ * 该类只持有 deps 引用,不直接 import 'electron';测试用 plain DI 注入 fake。
377
+ * 生命周期一次性:start → dispose 后不能再 start(避免 listener 泄露 / 句柄
378
+ * 双重注册);hot-restart 由 host 重新构造新实例。
379
+ *
380
+ * @internal
381
+ */
382
+ /**
383
+ * Framework-reserved invoke failure codes —— 全部带 `DECK_` 前缀,避免与
384
+ * host 自定义 code 冲突。Client / host 都不应抛同前缀。
385
+ */
386
+ var DECK_CODE = {
387
+ UntrustedSender: "DECK_UNTRUSTED_SENDER",
388
+ UntrustedFrame: "DECK_UNTRUSTED_FRAME",
389
+ UnknownKind: "DECK_UNKNOWN_KIND",
390
+ BadRequest: "DECK_BAD_REQUEST",
391
+ /** P4 Phase B — the grant gate denied a privileged command for this sender. */
392
+ Forbidden: "DECK_FORBIDDEN"
393
+ };
394
+ var WireTransport = class {
395
+ deps;
396
+ state = "idle";
397
+ busSubscription = null;
398
+ /** Whether the optional Place / LayoutSubscribe handlers were registered
399
+ * (either eagerly at start() via deps, or lazily via armSlotChannels) — so
400
+ * dispose() only removes channels it actually registered. */
401
+ placeRegistered = false;
402
+ layoutSubscribeRegistered = false;
403
+ /** Effective slot callbacks. Populated from deps at start() (eager path, used
404
+ * by the wire unit tests) OR by {@link armSlotChannels} (lazy path, used by
405
+ * deck-app so an app with no anchored views registers only Invoke + Probe). */
406
+ onPlaceCb = null;
407
+ onLayoutSubscribeCb = null;
408
+ constructor(deps) {
409
+ this.deps = deps;
410
+ }
411
+ start() {
412
+ if (this.state === "started") throw new Error("WireTransport already started");
413
+ if (this.state === "disposed") throw new Error("WireTransport already disposed (single-use lifecycle); construct a new instance to restart");
414
+ this.state = "started";
415
+ const cleanups = [];
416
+ try {
417
+ this.deps.ipcMain.handle(DeckChannel.Probe, () => this.handleProbe());
418
+ cleanups.push(() => this.tryRemoveHandler(DeckChannel.Probe));
419
+ this.deps.ipcMain.handle(DeckChannel.Invoke, (event, ...args) => this.handleInvoke(event?.sender?.id, event?.senderFrame, event?.sender?.mainFrame, args[0]));
420
+ cleanups.push(() => this.tryRemoveHandler(DeckChannel.Invoke));
421
+ if (this.deps.onPlace) {
422
+ this.onPlaceCb = this.deps.onPlace;
423
+ this.registerPlaceHandler();
424
+ cleanups.push(() => this.unregisterPlaceHandler());
425
+ }
426
+ if (this.deps.onLayoutSubscribe) {
427
+ this.onLayoutSubscribeCb = this.deps.onLayoutSubscribe;
428
+ this.registerLayoutSubscribeHandler();
429
+ cleanups.push(() => this.unregisterLayoutSubscribeHandler());
430
+ }
431
+ this.busSubscription = this.deps.bus.subscribeAll((name, payload) => {
432
+ this.fanoutEvent(name, payload);
433
+ });
434
+ cleanups.push(() => {
435
+ if (this.busSubscription) {
436
+ this.busSubscription.dispose();
437
+ this.busSubscription = null;
438
+ }
439
+ });
440
+ } catch (err) {
441
+ for (let i = cleanups.length - 1; i >= 0; i -= 1) try {
442
+ cleanups[i]?.();
443
+ } catch (cleanupErr) {
444
+ console.error("[electron-deck] start() rollback cleanup failed:", cleanupErr);
445
+ }
446
+ this.state = "idle";
447
+ throw err;
448
+ }
449
+ }
450
+ tryRemoveHandler(channel) {
451
+ try {
452
+ this.deps.ipcMain.removeHandler(channel);
453
+ } catch (e) {
454
+ console.error(`[electron-deck] removeHandler(${channel}) failed:`, e);
455
+ }
456
+ }
457
+ registerPlaceHandler() {
458
+ if (this.placeRegistered) return;
459
+ this.deps.ipcMain.handle(DeckChannel.Place, (event, ...args) => this.handlePlace(event?.sender?.id, event?.senderFrame, event?.sender?.mainFrame, args[0]));
460
+ this.placeRegistered = true;
461
+ }
462
+ unregisterPlaceHandler() {
463
+ if (!this.placeRegistered) return;
464
+ this.placeRegistered = false;
465
+ this.tryRemoveHandler(DeckChannel.Place);
466
+ }
467
+ registerLayoutSubscribeHandler() {
468
+ if (this.layoutSubscribeRegistered) return;
469
+ this.deps.ipcMain.handle(DeckChannel.LayoutSubscribe, (event) => this.handleLayoutSubscribe(event?.sender?.id, event?.senderFrame, event?.sender?.mainFrame));
470
+ this.layoutSubscribeRegistered = true;
471
+ }
472
+ unregisterLayoutSubscribeHandler() {
473
+ if (!this.layoutSubscribeRegistered) return;
474
+ this.layoutSubscribeRegistered = false;
475
+ this.tryRemoveHandler(DeckChannel.LayoutSubscribe);
476
+ }
477
+ /**
478
+ * Lazily register the slot-token inbound channels (`Place` + `LayoutSubscribe`)
479
+ * on an already-started wire, wiring them to the given callbacks. Idempotent:
480
+ * subsequent calls just refresh the callbacks (the handlers are registered
481
+ * once). deck-app calls this on the FIRST anchored `placeIn`, so an app with
482
+ * no anchored views never registers these channels (keeping the wire's
483
+ * handler footprint at Invoke + Probe until a slot is actually minted).
484
+ */
485
+ armSlotChannels(onPlace, onLayoutSubscribe) {
486
+ this.onPlaceCb = onPlace;
487
+ this.onLayoutSubscribeCb = onLayoutSubscribe;
488
+ if (this.state !== "started") return;
489
+ this.registerPlaceHandler();
490
+ this.registerLayoutSubscribeHandler();
491
+ }
492
+ dispose() {
493
+ if (this.state !== "started") {
494
+ this.state = "disposed";
495
+ return;
496
+ }
497
+ this.state = "disposed";
498
+ try {
499
+ this.deps.ipcMain.removeHandler(DeckChannel.Invoke);
500
+ } catch (e) {
501
+ console.error("[electron-deck] removeHandler(invoke) failed:", e);
502
+ }
503
+ try {
504
+ this.deps.ipcMain.removeHandler(DeckChannel.Probe);
505
+ } catch (e) {
506
+ console.error("[electron-deck] removeHandler(probe) failed:", e);
507
+ }
508
+ if (this.placeRegistered) {
509
+ this.placeRegistered = false;
510
+ try {
511
+ this.deps.ipcMain.removeHandler(DeckChannel.Place);
512
+ } catch (e) {
513
+ console.error("[electron-deck] removeHandler(place) failed:", e);
514
+ }
515
+ }
516
+ if (this.layoutSubscribeRegistered) {
517
+ this.layoutSubscribeRegistered = false;
518
+ try {
519
+ this.deps.ipcMain.removeHandler(DeckChannel.LayoutSubscribe);
520
+ } catch (e) {
521
+ console.error("[electron-deck] removeHandler(layout-subscribe) failed:", e);
522
+ }
523
+ }
524
+ if (this.busSubscription) {
525
+ this.busSubscription.dispose();
526
+ this.busSubscription = null;
527
+ }
528
+ }
529
+ handleProbe() {
530
+ return {
531
+ ready: true,
532
+ version: BRIDGE_PROTOCOL_VERSION
533
+ };
534
+ }
535
+ /**
536
+ * Defense-in-depth main-frame check (mirrors devtools' verified
537
+ * `isMainFrameSender`). A trusted webContents may embed a sub-frame of
538
+ * arbitrary origin; only its top frame should reach gated invoke, so a
539
+ * sub-frame can't spoof the trusted sender id.
540
+ *
541
+ * - NEITHER field modeled → frame-unaware stub → not a real frame boundary;
542
+ * the sender-id gate is the boundary → allow (back-compat with the legacy
543
+ * `{ sender: { id } }` stubs).
544
+ * - either null (incl. a real event whose `senderFrame` resolved to null after
545
+ * navigate-after-send / frame destruction, or a partial/malformed event) →
546
+ * fail-closed → reject.
547
+ * - main frame (routingId + processId match) → allow; else (sub frame) reject.
548
+ */
549
+ isMainFrameSender(senderFrame, main) {
550
+ if (senderFrame === void 0 && main === void 0) return true;
551
+ if (senderFrame == null || main == null) return false;
552
+ return senderFrame.routingId === main.routingId && senderFrame.processId === main.processId;
553
+ }
554
+ async handleInvoke(senderId, senderFrame, mainFrame, rawReq) {
555
+ const validation = validateRequest(rawReq);
556
+ if (!validation.ok) return failure(validation.name, validation.message, DECK_CODE.BadRequest);
557
+ const req = validation.req;
558
+ if (typeof senderId !== "number" || !this.deps.senderPolicy.isTrusted(senderId)) return failure(req.name, `untrusted sender (id=${String(senderId)})`, DECK_CODE.UntrustedSender);
559
+ if (!this.isMainFrameSender(senderFrame, mainFrame)) return failure(req.name, `untrusted frame (sender id=${String(senderId)})`, DECK_CODE.UntrustedFrame);
560
+ const ctx = {
561
+ senderId,
562
+ senderFrame: senderFrame ?? null
563
+ };
564
+ try {
565
+ if (req.kind === "host") return {
566
+ ok: true,
567
+ result: await this.deps.invokeHost(req.name, req.args, ctx)
568
+ };
569
+ if (req.kind === "simulator") return {
570
+ ok: true,
571
+ result: await this.deps.invokeSimulator(req.name, req.args, ctx)
572
+ };
573
+ return failure(req.name, `unknown invoke kind: ${String(req.kind)}`, DECK_CODE.UnknownKind);
574
+ } catch (err) {
575
+ return serializeError(req.name, err);
576
+ }
577
+ }
578
+ /**
579
+ * `__electron-deck:place` inbound. Same gate as {@link handleInvoke}
580
+ * (trust + main-frame). On any failure (malformed payload, untrusted sender,
581
+ * sub-frame) → DROP silently (return undefined; never call `onPlace`).
582
+ */
583
+ handlePlace(senderId, senderFrame, mainFrame, rawMsg) {
584
+ if (rawMsg === null || typeof rawMsg !== "object" || Array.isArray(rawMsg)) return;
585
+ const msg = rawMsg;
586
+ if (typeof msg.slotToken !== "string") return;
587
+ if (typeof senderId !== "number" || !this.deps.senderPolicy.isTrusted(senderId)) return;
588
+ if (!this.isMainFrameSender(senderFrame, mainFrame)) return;
589
+ this.onPlaceCb?.(senderId, msg.slotToken, msg.placement);
590
+ }
591
+ /**
592
+ * `__electron-deck:layout-subscribe` inbound. Same gate as {@link handleInvoke}.
593
+ * On gate pass → `onLayoutSubscribe(senderId)`; else DROP silently.
594
+ */
595
+ handleLayoutSubscribe(senderId, senderFrame, mainFrame) {
596
+ if (typeof senderId !== "number" || !this.deps.senderPolicy.isTrusted(senderId)) return;
597
+ if (!this.isMainFrameSender(senderFrame, mainFrame)) return;
598
+ this.onLayoutSubscribeCb?.(senderId);
599
+ }
600
+ fanoutEvent(name, payload) {
601
+ if (this.state !== "started") return;
602
+ if (!this.deps.declaredEvents().includes(name)) {
603
+ console.warn(`[electron-deck] dropping undeclared event "${name}" from wire fanout`);
604
+ return;
605
+ }
606
+ const env = {
607
+ name,
608
+ payload
609
+ };
610
+ let targets;
611
+ try {
612
+ targets = this.deps.trustedWebContents();
613
+ } catch (e) {
614
+ console.error("[electron-deck] trustedWebContents() threw:", e);
615
+ return;
616
+ }
617
+ for (const wc of targets) {
618
+ if (wc.isDestroyed()) continue;
619
+ try {
620
+ wc.send(DeckChannel.Event, env);
621
+ } catch (e) {
622
+ console.error(`[electron-deck] webContents.send("${DeckChannel.Event}") failed for wc#${wc.id}:`, e);
623
+ }
624
+ }
625
+ }
626
+ };
627
+ function validateRequest(raw) {
628
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {
629
+ ok: false,
630
+ name: "unknown",
631
+ message: "invoke request must be a plain object"
632
+ };
633
+ const { kind, name, args } = raw;
634
+ if (typeof kind !== "string") return {
635
+ ok: false,
636
+ name: typeof name === "string" ? name : "unknown",
637
+ message: "invoke request.kind must be a string"
638
+ };
639
+ if (typeof name !== "string") return {
640
+ ok: false,
641
+ name: "unknown",
642
+ message: "invoke request.name must be a string"
643
+ };
644
+ if (!Array.isArray(args)) return {
645
+ ok: false,
646
+ name,
647
+ message: "invoke request.args must be an array"
648
+ };
649
+ return {
650
+ ok: true,
651
+ req: {
652
+ kind,
653
+ name,
654
+ args
655
+ }
656
+ };
657
+ }
658
+ function failure(remoteName, message, code) {
659
+ return {
660
+ ok: false,
661
+ error: {
662
+ remoteName,
663
+ message,
664
+ code
665
+ }
666
+ };
667
+ }
668
+ function serializeError(invokeName, err) {
669
+ if (err instanceof DeckRemoteError) return failure(err.remoteName ?? invokeName, err.message, err.code);
670
+ if (err instanceof Error) {
671
+ const errAny = err;
672
+ const code = typeof errAny.code === "string" ? errAny.code : void 0;
673
+ return failure(invokeName, err.message, code);
674
+ }
675
+ let message;
676
+ try {
677
+ message = String(err);
678
+ } catch {
679
+ message = "unknown error (failed to stringify)";
680
+ }
681
+ return failure(invokeName, message);
682
+ }
683
+ //#endregion
684
+ //#region src/host/control-bus.ts
685
+ /**
686
+ * ControlBus — a thin, domain-neutral facade over the existing
687
+ * {@link WireTransport} + {@link EventBus} + an injectable refcount
688
+ * {@link TrustSet}.
689
+ *
690
+ * Three verbs, no new gating logic of its own:
691
+ *
692
+ * - `command(name, handler)` : webview → main RPC. The facade owns the single
693
+ * command table and exposes {@link ControlBus.dispatch}; the wire's
694
+ * `invokeHost` (and `invokeSimulator`) seam calls `dispatch(name, args)` so a
695
+ * real webview→main invoke (after the wire's sender + main-frame gate) lands on
696
+ * the registered handler. Callers never see the wire `kind`. Trust + main-frame
697
+ * gating are entirely the wire's (`handleInvoke`): a trusted main-frame sender
698
+ * reaches the handler, an untrusted sender → `DECK_UNTRUSTED_SENDER`, a
699
+ * sub-frame → `DECK_UNTRUSTED_FRAME`.
700
+ * - `event(name)` : main → webview push, default-deny. `name` is added to the
701
+ * declared-event allowlist the wire reads; `publish` goes through `bus.publish`
702
+ * → wire fanout (undeclared names are dropped by the wire). `dispose` revokes
703
+ * the declaration.
704
+ * - `trust(wc, owner)` : delegates to `trustSet.admit` (refcount membership owned
705
+ * by `owner` Scope, so the lease is released when the owner tears down).
706
+ *
707
+ * Real wiring (Bug C): the command table is the SOLE command authority. The
708
+ * production caller builds the {@link WireTransport} with
709
+ * `invokeHost = (name, args) => controlBus.dispatch(name, args)` (and the same
710
+ * for `invokeSimulator`, since the facade hides the kind), and `declaredEvents`
711
+ * reading {@link ControlBus.declaredEvents}. So a real IPC invoke reaches the
712
+ * command handler through the wire — not a private test-only seam. Any
713
+ * config-declared host services register into this SAME table via `command()`,
714
+ * so there is one namespace (the host keeps names unique), never two registries
715
+ * that can collide.
716
+ *
717
+ * @internal exported via `/host`.
718
+ */
719
+ function createControlBus(deps) {
720
+ const { bus, trustSet, policy } = deps;
721
+ const commandRegistry = /* @__PURE__ */ new Map();
722
+ const declaredEventSet = /* @__PURE__ */ new Set();
723
+ return {
724
+ command(name, handler) {
725
+ commandRegistry.set(name, handler);
726
+ let disposed = false;
727
+ return { dispose: () => {
728
+ if (disposed) return;
729
+ disposed = true;
730
+ if (commandRegistry.get(name) === handler) commandRegistry.delete(name);
731
+ } };
732
+ },
733
+ event(name) {
734
+ declaredEventSet.add(name);
735
+ let disposed = false;
736
+ return {
737
+ publish: (payload) => {
738
+ bus.publish(name, payload);
739
+ },
740
+ dispose: () => {
741
+ if (disposed) return;
742
+ disposed = true;
743
+ declaredEventSet.delete(name);
744
+ }
745
+ };
746
+ },
747
+ trust(wc, owner) {
748
+ return trustSet.admit(wc, owner);
749
+ },
750
+ async dispatch(name, args, ctx) {
751
+ const handler = commandRegistry.get(name);
752
+ if (!handler) throw new Error(`no command registered: ${name}`);
753
+ if (policy && !policy.allows(ctx.senderId, name)) throw new DeckRemoteError(name, `forbidden: ${name}`, DECK_CODE.Forbidden);
754
+ return handler(...args);
755
+ },
756
+ declaredEvents() {
757
+ return Array.from(declaredEventSet);
758
+ }
759
+ };
760
+ }
761
+ //#endregion
762
+ //#region src/internal/deck-app.ts
763
+ var SIMULATOR_CHANNEL_PREFIX = "__electron-deck:simulator:";
764
+ var HOST_CHANNEL_PREFIX = "__electron-deck:host:";
765
+ var DEFAULT_MAIN_WIDTH = 1024;
766
+ var DEFAULT_MAIN_HEIGHT = 768;
767
+ /**
768
+ * Framework-internal "app" object —— `electronDeck(config)` 顶层入口的 plain-class
769
+ * 形态,便于 Phase 2 测试驱动 lifecycle 转换。Phase 3b 加 wireTransport 注入
770
+ * 后可接真 ipcMain;Phase 4 加 electron 注入后可装配 mainWindow / toolbarView
771
+ * / declared windows。两者都不注入时退化为同进程内存 fake。
772
+ *
773
+ * @internal
774
+ */
775
+ var DeckApp = class {
776
+ config;
777
+ lifecycle = new LifecycleManager();
778
+ registry = new ResourceRegistryImpl();
779
+ bus = new EventBus();
780
+ ipc = new InMemoryTypedIpcRegistry();
781
+ fwListeners = /* @__PURE__ */ new Map();
782
+ trustSet = createTrustSet();
783
+ /** P4 Phase B — privileged-command grant registry. The policy gates
784
+ * ControlBus.dispatch; grants are minted via `runtime.grants.issue`. */
785
+ capability = createCapabilityRegistry();
786
+ /** P4 Phase B — the grant-gated command bus for PRIVILEGED `layout.*`
787
+ * commands. Constructed in `bindWireTransport` with the capability policy
788
+ * injected, so `dispatch` default-DENIES any command lacking a live grant.
789
+ * Privileged commands are registered via `runtime.layout.command`; ordinary
790
+ * domain APIs stay on the un-gated `InMemoryTypedIpcRegistry`. */
791
+ controlBus = null;
792
+ /** v2 — per-webContents backend `onWindowTrusted` Disposable, so a window's
793
+ * trust mirror is undone when THAT window closes (not only at teardown). */
794
+ backendTrustDisposables = /* @__PURE__ */ new Map();
795
+ options;
796
+ wireTransport = null;
797
+ /** v2 — the live wire senderPolicy, reused by buildRuntime so
798
+ * `context._senderPolicy` reflects real trust instead of a `() => true` stub. */
799
+ wireSenderPolicy = null;
800
+ mainWindow = null;
801
+ toolbarView = null;
802
+ declaredWindows = /* @__PURE__ */ new Map();
803
+ trackedWindows = /* @__PURE__ */ new Set();
804
+ /**
805
+ * unified-lifetime P0 (observation-only, zero-regression). The root lifetime
806
+ * scope of the app + a shadow map mirroring `trackedWindows`, keyed by each
807
+ * window's webContents, carrying a per-window child Scope. P0 is PURELY
808
+ * OBSERVATIONAL: these windowScopes own NO resources (no destroy/trust/wire is
809
+ * moved onto them), so `rootScope.close()` during shutdown is a no-op for app
810
+ * behaviour — it exists only to lay the foundation P1 will take over. The
811
+ * shadow's key set is kept in lock-step with `trackedWindows` at every
812
+ * maintenance point.
813
+ */
814
+ rootScope = createScope();
815
+ lifetimeShadow = /* @__PURE__ */ new Map();
816
+ /**
817
+ * unified-lifetime P1b: per-trusted-webContents trust record. `wcScope` is a
818
+ * child of the owning window's `windowScope`; it OWNS the wc's trust ref-count
819
+ * lease(s). When the window closes, `windowScope.close()` cascades into this
820
+ * `wcScope` (children-first LIFO), disposing every lease → ref-count hits 0 →
821
+ * the wc leaves the trust set. This replaces the imperative `deleteEntry`.
822
+ * A wc can be trusted more than once (framework auto-trust + host
823
+ * `windows.trust`), so `leases` is a Set.
824
+ */
825
+ wcRecords = /* @__PURE__ */ new Map();
826
+ pendingWindowCreated = [];
827
+ pendingLoadFailed = [];
828
+ /**
829
+ * host-view slice 1 — per-window native-view substrate, keyed by the window's
830
+ * webContents (same key discipline as `lifetimeShadow`). Created at both
831
+ * window-construction sites; dropped in `handleSubWindowClosed`.
832
+ */
833
+ windowSubstrates = /* @__PURE__ */ new Map();
834
+ /**
835
+ * P3 — per-adopted-window registration handle, keyed by the adopted window's
836
+ * webContents. `runtime.windows.adopt` is idempotent by wc identity: a second
837
+ * adopt of the same window returns this stored Disposable (no double-admit, no
838
+ * second substrate). The entry is removed when the registration is disposed
839
+ * (early un-adopt) or the window's windowScope closes.
840
+ */
841
+ adoptedWindows = /* @__PURE__ */ new Map();
842
+ /**
843
+ * C1/C4 — per-registered-window {@link DeckWindow} facade + its per-window
844
+ * close deciders, keyed by the window's control wc. Populated by
845
+ * {@link registerWindow} for framework-created and adopted windows, plus the
846
+ * framework-built main window. `deciders` is an ORDERED list run in
847
+ * registration order on a close attempt; `closing` is the per-window in-flight
848
+ * decision latch (mirrors the main-window `closingDecisionPromise`).
849
+ */
850
+ windowRegistrations = /* @__PURE__ */ new Map();
851
+ /** C1 — the framework-built main window's control wc, so `runtime.windows.main`
852
+ * can resolve its {@link DeckWindow}. Null under an `ownsWindows:true` backend. */
853
+ mainControlWc = null;
854
+ /** host-view slice 1 — monotonic id source for `runtime.view` native views. */
855
+ viewSeq = 0;
856
+ /**
857
+ * P2 — provenance map for `runtime.scopes.create()` sessions: maps an opaque
858
+ * {@link DeckSession} to its internal `rootScope.child()` Scope. A WeakMap so a
859
+ * dropped session is GC'd; the framework holds the scope's lifetime via
860
+ * rootScope anyway (the session scope is a rootScope child). `runtime.view`
861
+ * resolves a passed session through this map — a foreign/raw Scope is absent
862
+ * and therefore REJECTED.
863
+ */
864
+ sessions = /* @__PURE__ */ new WeakMap();
865
+ /**
866
+ * keepAlive B3.2 — opt-in per-group LRU of HIDDEN keep-alive views. Group key is
867
+ * `lru:${max}` (all `keepAlive:{policy:'lru',max:N}` views share one group per
868
+ * `max`). Each group holds an ORDERED list of HIDDEN view ids (front = least
869
+ * recently visible = first to evict) + a map from view id to its host handle so
870
+ * an eviction can dispose it (→ its WebContents is destroyed). Views created
871
+ * without `keepAlive` never participate.
872
+ */
873
+ keepAliveGroups = /* @__PURE__ */ new Map();
874
+ /** C3: wcs that already have the `did-start-navigation` grant-reset hook bound,
875
+ * so {@link bindNavigationGrantReset} is idempotent — a wc admitted to trust
876
+ * more than once (e.g. constructed `autoTrust:false` then `windows.trust()`ed,
877
+ * or un-adopted then re-adopted) never accumulates duplicate nav listeners. */
878
+ navHookBound = /* @__PURE__ */ new WeakSet();
879
+ /**
880
+ * slot-token registry (build-plan §2(e) / capability-and-lifecycle §A5-2):
881
+ * each anchored `placeIn` mints an unguessable token bound to (viewId, slotId,
882
+ * authorizedWcId). The `__electron-deck:place` apply path looks the token up,
883
+ * checks the sender is the authorized wc (anti-spoof), validates the placement,
884
+ * then `apply`s it. `resend` re-pushes the slot-grant (layout-subscribe replay).
885
+ */
886
+ slotTokens = /* @__PURE__ */ new Map();
887
+ slotSeq = 0;
888
+ _runtime = null;
889
+ startCalled = false;
890
+ shutdownPromise = null;
891
+ /** Set when shutdown is driven by the `will-quit` handler — the app is already
892
+ * quitting, so `doShutdown()` must NOT re-`app.quit()` (re-entrant quit). */
893
+ quitInitiated = false;
894
+ /** v2 close machine: non-null while a close decision is awaiting (in-flight latch). */
895
+ closingDecisionPromise = null;
896
+ /** v2 close machine: set once a 'close' decision is committed (guards the
897
+ * window between decision-resolve and the 'closed' event). Never resets. */
898
+ shuttingDown = false;
899
+ /** Idempotency latch for {@link runShutdownCleanup}: the cleanup body
900
+ * (beforeClose → backend.onShutdown → rootScope.close) must run AT MOST ONCE.
901
+ * Both `doShutdown` (single-flight via shutdownPromise) AND `cleanupOnError`
902
+ * (which bypasses that promise) reach here; and `rootScope.close()` inside the
903
+ * body can synchronously fire a framework window's `'closed'` → `shutdown()`
904
+ * re-entry. Without this latch that re-entry would invoke `backend.onShutdown`
905
+ * a second time.
906
+ *
907
+ * This is a JOIN, not a boolean early-return: a re-entrant caller AWAITS the
908
+ * SAME in-flight cleanup promise instead of returning immediately. A plain
909
+ * `if (ran) return` let a concurrent shutdown skip past an in-flight cleanup
910
+ * and force `app.quit()` mid-teardown (truncation). Never resets. */
911
+ cleanupPromise = null;
912
+ constructor(config, options = {}) {
913
+ this.config = config;
914
+ this.options = options;
915
+ this.rootScope.own(() => {
916
+ this.bus.unbindAll();
917
+ });
918
+ this.rootScope.own(async () => {
919
+ try {
920
+ await this.registry.disposeAll();
921
+ } catch (e) {
922
+ console.error("[electron-deck] registry.disposeAll failed:", e);
923
+ }
924
+ });
925
+ }
926
+ get phase() {
927
+ return this.lifecycle.current;
928
+ }
929
+ get runtime() {
930
+ if (this._runtime === null) throw new Error("DeckApp.runtime is unavailable before setup phase");
931
+ return this._runtime;
932
+ }
933
+ async start() {
934
+ if (this.startCalled) throw new Error("DeckApp.start() already called");
935
+ this.startCalled = true;
936
+ validateConfig(this.config);
937
+ const hasWebviewContent = !!(this.config.toolbar || this.config.windows);
938
+ if (this.options.electron && hasWebviewContent && !this.options.wireTransport) throw new Error("DeckAppOptions: wireTransport.ipcMain is required when config has toolbar or windows");
939
+ const app = this.options.electron?.app;
940
+ if (app) {
941
+ if (this.config.app?.singleInstance && typeof app.requestSingleInstanceLock === "function") {
942
+ if (!app.requestSingleInstanceLock()) {
943
+ try {
944
+ app.quit();
945
+ } catch {}
946
+ return;
947
+ }
948
+ }
949
+ if (this.options.backend?.beforeReady) await this.options.backend.beforeReady(app);
950
+ else if (this.config.app?.name) try {
951
+ app.setName(this.config.app.name);
952
+ } catch {}
953
+ await app.whenReady();
954
+ this.bindAppLifecycle(app);
955
+ }
956
+ try {
957
+ this.lifecycle.enter("bind");
958
+ this.bindDeclarativeFields();
959
+ this.assembleElectron();
960
+ this.bindWireTransport();
961
+ this.loadAssembledSources();
962
+ this.lifecycle.enter("setup");
963
+ this._runtime = this.buildRuntime();
964
+ if (this.options.backend) await this.options.backend.assemble(this._runtime);
965
+ if (this.config.setup) await this.config.setup(this._runtime);
966
+ this.lifecycle.enter("ready");
967
+ } catch (err) {
968
+ await this.cleanupOnError();
969
+ throw err;
970
+ }
971
+ }
972
+ /**
973
+ * Process-level Electron lifecycle bindings (post-whenReady, independent of
974
+ * `ownsWindows` — these are app events, not window events):
975
+ * - `will-quit` → framework teardown (idempotent via `shutdownPromise`).
976
+ * - `window-all-closed` → bound ONLY when `quitOnAllWindowsClosed` is set
977
+ * (opt-in; omitted leaves Electron's default / the consumer's own handler).
978
+ * - `second-instance` → backend hook, bound only under `singleInstance`.
979
+ */
980
+ bindAppLifecycle(app) {
981
+ app.on("will-quit", () => {
982
+ this.quitInitiated = true;
983
+ this.shutdown();
984
+ });
985
+ const quitOnAllClosed = this.config.app?.quitOnAllWindowsClosed;
986
+ if (quitOnAllClosed !== void 0) app.on("window-all-closed", () => {
987
+ if (quitOnAllClosed) try {
988
+ app.quit();
989
+ } catch {}
990
+ });
991
+ if (this.config.app?.singleInstance) app.on("second-instance", () => {
992
+ this.options.backend?.onSecondInstance?.();
993
+ });
994
+ }
995
+ shutdown() {
996
+ if (this.shutdownPromise) return this.shutdownPromise;
997
+ this.shutdownPromise = this.doShutdown();
998
+ return this.shutdownPromise;
999
+ }
1000
+ /**
1001
+ * @internal Phase 4 windows.trust() / framework 内部添加 trusted webContents.
1002
+ * Backend-owned / untracked-window fallback: there is no framework windowScope
1003
+ * for these (the backend manages their lifetime), so the trust lease is owned
1004
+ * by `rootScope` as an app-shutdown backstop. The backend still disposes the
1005
+ * returned handle early when it untrusts / destroys its own window.
1006
+ */
1007
+ _trustWebContents(wc) {
1008
+ return this.trustSet.admit(wc, this.rootScope);
1009
+ }
1010
+ /**
1011
+ * unified-lifetime P1b: admit `wc` to the trust set under `windowScope`. Gets
1012
+ * or creates the wc's `wcScope` (a child of `windowScope`), takes a fresh
1013
+ * `trustSet.admit(wc, wcScope)` ref-count lease OWNED BY that wcScope, and returns a
1014
+ * one-shot host-facing Disposable that releases just THIS lease early. On
1015
+ * `wcScope.close()` (window-close cascade) every still-held lease is disposed
1016
+ * → ref-count zeroes → the wc leaves the set (equivalent to the old
1017
+ * `deleteEntry`, but driven by Scope teardown and covering partially-built
1018
+ * windows too). Idempotent registry cleanup is owned by the wcScope.
1019
+ */
1020
+ admitTrust(wc, windowScope) {
1021
+ if (wc.isDestroyed()) return { dispose: () => {} };
1022
+ let rec = this.wcRecords.get(wc);
1023
+ if (!rec) {
1024
+ const wcScope = windowScope.child();
1025
+ rec = {
1026
+ wcScope,
1027
+ leases: /* @__PURE__ */ new Set(),
1028
+ windowScope
1029
+ };
1030
+ this.wcRecords.set(wc, rec);
1031
+ wcScope.own(() => {
1032
+ this.wcRecords.delete(wc);
1033
+ });
1034
+ }
1035
+ const record = rec;
1036
+ const lease = this.trustSet.admit(wc, record.wcScope);
1037
+ record.leases.add(lease);
1038
+ let released = false;
1039
+ return { dispose: () => {
1040
+ if (released) return;
1041
+ released = true;
1042
+ record.leases.delete(lease);
1043
+ lease.dispose();
1044
+ } };
1045
+ }
1046
+ /**
1047
+ * C3 (P0 security) — bind the main-frame cross-document navigation grant-reset
1048
+ * hook on a control `wc`. On a MAIN-FRAME CROSS-DOCUMENT navigation
1049
+ * (`isMainFrame && !isInPlace`) this SYNCHRONOUSLY revokes the wc's capability
1050
+ * grants (`capability.revokeBySenderId`) and slot tokens, so the navigated-to
1051
+ * document can't inherit the prior page's privileges. Trust is LEFT INTACT (the
1052
+ * wc stays the framework's control surface) — we deliberately do NOT tear down
1053
+ * its `wcScope`, because that would dispose the trust lease and force an async
1054
+ * re-admit gap that breaks `runtime.grants.issue`. In-place (hash/pushState) and
1055
+ * sub-frame navigations are ignored; the initial document load is a no-op (no
1056
+ * grants exist yet).
1057
+ *
1058
+ * Must be called AFTER {@link admitTrust} so the wc's `wcScope` exists. The
1059
+ * `wc.on` guard tolerates the minimal fakes that lack an EventEmitter surface.
1060
+ */
1061
+ bindNavigationGrantReset(wc) {
1062
+ const on = wc.on;
1063
+ if (typeof on !== "function") return;
1064
+ if (this.navHookBound.has(wc)) return;
1065
+ this.navHookBound.add(wc);
1066
+ on.call(wc, "did-start-navigation", (_event, _url, isInPlace, isMainFrame) => {
1067
+ if (isMainFrame !== true || isInPlace === true) return;
1068
+ if (!this.wcRecords.get(wc)) return;
1069
+ this.capability.revokeBySenderId(wc.id);
1070
+ this.revokeSlotTokensForWc(wc.id);
1071
+ });
1072
+ }
1073
+ /**
1074
+ * C1 — build the {@link DeckWindow} facade over a registered window and record
1075
+ * it (so `runtime.windows.create()` / `runtime.windows.main` can return it and
1076
+ * the per-window close machine can find its deciders). `newSession()` mints a
1077
+ * window-rooted {@link DeckSession} (a `windowScope.child()` registered in the
1078
+ * SAME provenance WeakMap as `runtime.scopes.create()`); `onClose()` appends a
1079
+ * per-window decider.
1080
+ */
1081
+ buildDeckWindow(win, controlWc, windowScope) {
1082
+ const deckWindow = {
1083
+ window: win,
1084
+ controlWc,
1085
+ newSession: () => {
1086
+ const scope = windowScope.child();
1087
+ const session = {
1088
+ reset: () => scope.reset(),
1089
+ dispose: () => scope.close()
1090
+ };
1091
+ this.sessions.set(session, scope);
1092
+ return session;
1093
+ },
1094
+ onClose: (decider) => {
1095
+ const rec = this.windowRegistrations.get(controlWc);
1096
+ if (!rec) return { dispose: () => {} };
1097
+ rec.deciders.push(decider);
1098
+ let removed = false;
1099
+ return { dispose: () => {
1100
+ if (removed) return;
1101
+ removed = true;
1102
+ const i = rec.deciders.indexOf(decider);
1103
+ if (i >= 0) rec.deciders.splice(i, 1);
1104
+ } };
1105
+ }
1106
+ };
1107
+ this.windowRegistrations.set(controlWc, {
1108
+ window: win,
1109
+ controlWc,
1110
+ windowScope,
1111
+ deckWindow,
1112
+ deciders: [],
1113
+ closing: null,
1114
+ committedClose: false
1115
+ });
1116
+ windowScope.own(() => {
1117
+ this.windowRegistrations.delete(controlWc);
1118
+ });
1119
+ return deckWindow;
1120
+ }
1121
+ /**
1122
+ * Resolve a window argument that may be either a raw `BrowserWindow` (the
1123
+ * historical `runtime.view().placeIn(window)` shape) OR a {@link DeckWindow}
1124
+ * handle (the C1 `runtime.windows.create()` return). A DeckWindow exposes the
1125
+ * underlying window via `.window` and has no `.webContents` of its own; a raw
1126
+ * window has `.webContents`. Unwrap so the substrate lookup keys off the real
1127
+ * control webContents either way — keeping the `placeIn(deckWindow)` and
1128
+ * `placeIn(deckWindow.window)` call shapes both valid (additive, no break).
1129
+ */
1130
+ resolveWindowArg(win) {
1131
+ const maybe = win;
1132
+ if (maybe && maybe.webContents === void 0 && maybe.window !== void 0) return maybe.window;
1133
+ return win;
1134
+ }
1135
+ /**
1136
+ * C4 — run a registered window's per-window close deciders (registration order;
1137
+ * any `'keep'` vetoes), with an in-flight latch so a re-entrant close during a
1138
+ * pending decision decides ONCE. A throw / rejection fails CLOSED. Returns the
1139
+ * committed decision, or `null` when the decision is still in flight / there are
1140
+ * no deciders (caller falls back). Mirrors the main-window decision machine.
1141
+ */
1142
+ async runWindowCloseDeciders(controlWc) {
1143
+ const rec = this.windowRegistrations.get(controlWc);
1144
+ if (!rec) return "close";
1145
+ const deciders = rec.deciders.slice();
1146
+ for (const decide of deciders) {
1147
+ let decision;
1148
+ try {
1149
+ decision = await decide();
1150
+ } catch (err) {
1151
+ console.error("[electron-deck] per-window onClose decider threw; closing:", err);
1152
+ decision = "close";
1153
+ }
1154
+ if (decision === "keep") return "keep";
1155
+ }
1156
+ return "close";
1157
+ }
1158
+ /**
1159
+ * C4 — arm the per-window cancelable close-decision machine on a CREATED /
1160
+ * adopted window. On a `close` attempt: `preventDefault`, dispatch the
1161
+ * per-window deciders behind an in-flight latch (re-entrant closes swallowed),
1162
+ * and on a `'close'` decision `win.destroy()` (→ `'closed'` → revoke +
1163
+ * windowScope cascade). A `'keep'` clears the latch so the next close re-decides.
1164
+ * Only armed when at least one decider can exist (i.e. the window has a
1165
+ * registration) — windows with no per-window deciders fall through to Electron's
1166
+ * default close (the framework does not preventDefault).
1167
+ */
1168
+ armSubWindowCloseMachine(win, controlWc) {
1169
+ win.on("close", (e) => {
1170
+ const rec = this.windowRegistrations.get(controlWc);
1171
+ if (!rec || rec.deciders.length === 0) return;
1172
+ e?.preventDefault?.();
1173
+ if (rec.closing) return;
1174
+ if (rec.committedClose) return;
1175
+ let settle;
1176
+ rec.closing = new Promise((res) => {
1177
+ settle = res;
1178
+ });
1179
+ this.runWindowCloseDeciders(controlWc).then((d) => settle(d), (err) => {
1180
+ console.error("[electron-deck] per-window close decision rejected; closing:", err);
1181
+ settle("close");
1182
+ });
1183
+ rec.closing.then((decision) => {
1184
+ if (decision === "keep") {
1185
+ rec.closing = null;
1186
+ return;
1187
+ }
1188
+ rec.committedClose = true;
1189
+ rec.closing = null;
1190
+ if (!win.isDestroyed()) win.destroy();
1191
+ });
1192
+ });
1193
+ }
1194
+ /**
1195
+ * @internal unified-lifetime P0 accessors (observation-only). The live shadow
1196
+ * map mirroring `trackedWindows`, the root Scope, and a consistency assertion.
1197
+ */
1198
+ __lifetimeShadow() {
1199
+ return this.lifetimeShadow;
1200
+ }
1201
+ /** @internal unified-lifetime P0: the root lifetime Scope. */
1202
+ __rootScope() {
1203
+ return this.rootScope;
1204
+ }
1205
+ /** @internal unified-lifetime P1b: the per-trusted-wc trust records. */
1206
+ __wcRecords() {
1207
+ return this.wcRecords;
1208
+ }
1209
+ /** @internal P4 Phase B: the live capability policy (grant gate reads it). */
1210
+ __capabilityPolicy() {
1211
+ return this.capability.policy;
1212
+ }
1213
+ /**
1214
+ * @internal unified-lifetime P0 invariant (A): the shadow's per-window set
1215
+ * (the `window` of each entry) must equal `trackedWindows` membership. Throws
1216
+ * if violated; no-op otherwise.
1217
+ */
1218
+ __assertLifetimeConsistent() {
1219
+ const shadowWindows = /* @__PURE__ */ new Set();
1220
+ for (const entry of this.lifetimeShadow.values()) shadowWindows.add(entry.window);
1221
+ if (shadowWindows.size !== this.trackedWindows.size) throw new Error(`lifetime shadow inconsistent: shadow has ${shadowWindows.size} windows, trackedWindows has ${this.trackedWindows.size}`);
1222
+ for (const win of this.trackedWindows) if (!shadowWindows.has(win)) throw new Error("lifetime shadow inconsistent: a trackedWindows member is absent from the shadow");
1223
+ }
1224
+ bindDeclarativeFields() {
1225
+ if (this.config.events) this.bus.bindDeclaredEvents(this.config.events);
1226
+ if (this.config.simulatorApis) for (const [name, handler] of Object.entries(this.config.simulatorApis)) {
1227
+ const d = this.ipc.handle(`${SIMULATOR_CHANNEL_PREFIX}${name}`, handler);
1228
+ this.registry.add(d);
1229
+ }
1230
+ if (this.config.hostServices) for (const [name, handler] of Object.entries(this.config.hostServices)) {
1231
+ if (name.startsWith("layout.")) throw new Error(`config.hostServices: "layout.*" names are reserved for privileged ControlBus commands (use runtime.layout.command); got: ${name}`);
1232
+ const d = this.ipc.handle(`${HOST_CHANNEL_PREFIX}${name}`, handler);
1233
+ this.registry.add(d);
1234
+ }
1235
+ }
1236
+ /**
1237
+ * host-view slice 1 — build a per-window {@link ViewSubstrate}. The
1238
+ * `ContentViewHost` adapts `win.contentView` and tracks the Compositor-managed
1239
+ * z-order in `order` (the minimal `contentView` has no `children()`). The
1240
+ * substrate's `detachAll` is owned on `windowScope` AFTER the window's
1241
+ * `win.destroy` own, so LIFO teardown runs detachAll (STEP1) BEFORE destroy
1242
+ * (STEP4) — the A4 ordering.
1243
+ */
1244
+ createWindowSubstrate(win, windowScope) {
1245
+ const wcvById = /* @__PURE__ */ new Map();
1246
+ const order = [];
1247
+ const compositor = createCompositor({
1248
+ addChildView: (ref) => {
1249
+ const wcv = wcvById.get(ref.id);
1250
+ if (!wcv) return;
1251
+ win.contentView.addChildView(wcv);
1252
+ const i = order.indexOf(ref.id);
1253
+ if (i >= 0) order.splice(i, 1);
1254
+ order.push(ref.id);
1255
+ },
1256
+ removeChildView: (ref) => {
1257
+ const wcv = wcvById.get(ref.id);
1258
+ if (!wcv) return;
1259
+ win.contentView.removeChildView(wcv);
1260
+ const i = order.indexOf(ref.id);
1261
+ if (i >= 0) order.splice(i, 1);
1262
+ },
1263
+ get isDestroyed() {
1264
+ return win.isDestroyed();
1265
+ },
1266
+ children: () => order.map((id) => ({ id }))
1267
+ });
1268
+ windowScope.own(() => {
1269
+ compositor.detachAll?.();
1270
+ });
1271
+ return {
1272
+ compositor,
1273
+ windowScope,
1274
+ registerView: (id, wcv) => {
1275
+ wcvById.set(id, wcv);
1276
+ },
1277
+ unregisterView: (id) => {
1278
+ wcvById.delete(id);
1279
+ const i = order.indexOf(id);
1280
+ if (i >= 0) order.splice(i, 1);
1281
+ }
1282
+ };
1283
+ }
1284
+ assembleElectron() {
1285
+ const electron = this.options.electron;
1286
+ if (!electron) return;
1287
+ if (this.options.backend?.ownsWindows) return;
1288
+ const appCfg = this.config.app ?? {};
1289
+ const winCfg = appCfg.window ?? {};
1290
+ const mainOpts = {
1291
+ ...appCfg.name !== void 0 ? { title: appCfg.name } : {},
1292
+ ...appCfg.icon !== void 0 ? { icon: appCfg.icon } : {},
1293
+ width: winCfg.width ?? DEFAULT_MAIN_WIDTH,
1294
+ height: winCfg.height ?? DEFAULT_MAIN_HEIGHT
1295
+ };
1296
+ if (winCfg.minWidth !== void 0) mainOpts.minWidth = winCfg.minWidth;
1297
+ if (winCfg.minHeight !== void 0) mainOpts.minHeight = winCfg.minHeight;
1298
+ if (winCfg.show !== void 0) mainOpts.show = winCfg.show;
1299
+ if (winCfg.backgroundColor !== void 0) mainOpts.backgroundColor = winCfg.backgroundColor;
1300
+ const backendPrefs = this.options.backend?.mainWindowWebPreferences?.();
1301
+ if (winCfg.webPreferences || backendPrefs) mainOpts.webPreferences = {
1302
+ ...winCfg.webPreferences,
1303
+ ...backendPrefs
1304
+ };
1305
+ const main = new electron.BrowserWindow(mainOpts);
1306
+ this.mainWindow = main;
1307
+ this.trackedWindows.add(main);
1308
+ const mainWindowScope = this.rootScope.child();
1309
+ mainWindowScope.own(() => {
1310
+ if (!main.isDestroyed()) main.destroy();
1311
+ });
1312
+ this.lifetimeShadow.set(main.webContents, {
1313
+ window: main,
1314
+ windowScope: mainWindowScope
1315
+ });
1316
+ this.windowSubstrates.set(main.webContents, this.createWindowSubstrate(main, mainWindowScope));
1317
+ const mainWcId = main.webContents.id;
1318
+ main.on("closed", () => {
1319
+ this.revokeWindowTrust(mainWindowScope);
1320
+ this.revokeSlotTokensForWc(mainWcId);
1321
+ });
1322
+ this.options.backend?.onMainWindowCreated?.(main, electron);
1323
+ this.admitTrust(main.webContents, mainWindowScope);
1324
+ const mainControlWc = main.webContents;
1325
+ this.bindNavigationGrantReset(mainControlWc);
1326
+ this.buildDeckWindow(main, mainControlWc, mainWindowScope);
1327
+ this.mainControlWc = mainControlWc;
1328
+ this._notifyBackendTrusted(main.webContents);
1329
+ this.pendingWindowCreated.push({
1330
+ window: main,
1331
+ role: "main"
1332
+ });
1333
+ main.on("resize", () => {
1334
+ this.options.backend?.repositionOverlays?.(main);
1335
+ if (!this.toolbarView || !this.config.toolbar) return;
1336
+ const b = main.getContentBounds();
1337
+ this.toolbarView.setBounds({
1338
+ x: 0,
1339
+ y: 0,
1340
+ width: b.width,
1341
+ height: this.config.toolbar.height
1342
+ });
1343
+ });
1344
+ main.on("close", (e) => {
1345
+ e?.preventDefault?.();
1346
+ if (this.closingDecisionPromise) return;
1347
+ if (this.shuttingDown) return;
1348
+ let settle;
1349
+ this.closingDecisionPromise = new Promise((res) => {
1350
+ settle = res;
1351
+ });
1352
+ let decide;
1353
+ const mainReg = this.windowRegistrations.get(mainControlWc);
1354
+ if (mainReg && mainReg.deciders.length > 0) decide = this.runWindowCloseDeciders(mainControlWc);
1355
+ else try {
1356
+ decide = this.options.backend?.onMainWindowClose?.() ?? "close";
1357
+ } catch (err) {
1358
+ console.error("[electron-deck] onMainWindowClose threw (sync); closing:", err);
1359
+ decide = "close";
1360
+ }
1361
+ Promise.resolve(decide).then((d) => settle(d === "keep" ? "keep" : "close"), (err) => {
1362
+ console.error("[electron-deck] onMainWindowClose threw; closing:", err);
1363
+ settle("close");
1364
+ });
1365
+ this.closingDecisionPromise.then((decision) => {
1366
+ if (decision === "keep") {
1367
+ this.closingDecisionPromise = null;
1368
+ return;
1369
+ }
1370
+ this.shuttingDown = true;
1371
+ this.closingDecisionPromise = null;
1372
+ if (!main.isDestroyed()) main.destroy();
1373
+ });
1374
+ });
1375
+ main.on("closed", () => {
1376
+ this.revokeWindowTrust(mainWindowScope);
1377
+ mainWindowScope.close().catch((e) => {
1378
+ console.error("[electron-deck] mainWindowScope.close() failed:", e);
1379
+ }).finally(() => {
1380
+ this.shutdown();
1381
+ });
1382
+ });
1383
+ if (this.config.toolbar) {
1384
+ const tb = this.config.toolbar;
1385
+ const view = new electron.WebContentsView({ webPreferences: { preload: tb.preloadPath } });
1386
+ this.toolbarView = view;
1387
+ main.contentView.addChildView(view);
1388
+ const bounds = main.getContentBounds();
1389
+ view.setBounds({
1390
+ x: 0,
1391
+ y: 0,
1392
+ width: bounds.width,
1393
+ height: tb.height
1394
+ });
1395
+ this.admitTrust(view.webContents, mainWindowScope);
1396
+ this.pendingWindowCreated.push({
1397
+ window: main,
1398
+ role: "toolbar"
1399
+ });
1400
+ }
1401
+ if (this.config.windows) for (const [key, contrib] of Object.entries(this.config.windows)) {
1402
+ const win = this.constructWindow(contrib, true, true);
1403
+ this.declaredWindows.set(key, win);
1404
+ this.pendingWindowCreated.push({
1405
+ window: win,
1406
+ role: "host"
1407
+ });
1408
+ }
1409
+ }
1410
+ /** Phase 4 race fix:在 wireTransport.start 之后再 loadURL/loadFile。 */
1411
+ loadAssembledSources() {
1412
+ if (this.toolbarView && this.config.toolbar) this.safeLoad(this.toolbarView.webContents, this.config.toolbar.source);
1413
+ if (this.config.windows) for (const [key, contrib] of Object.entries(this.config.windows)) {
1414
+ const win = this.declaredWindows.get(key);
1415
+ if (win) this.safeLoad(win.webContents, contrib.source);
1416
+ }
1417
+ if (this.mainWindow && this.config.app?.source) this.safeLoad(this.mainWindow.webContents, this.config.app.source);
1418
+ }
1419
+ /**
1420
+ * #3 R5/C2 — load is best-effort: log + emit `load-failed` if it rejects,
1421
+ * but never let start() reject because of a renderer load issue (we'd
1422
+ * leave the host blocked indefinitely while the framework is otherwise
1423
+ * happy).
1424
+ */
1425
+ safeLoad(wc, source) {
1426
+ if ("url" in source) wc.loadURL(source.url).catch((err) => {
1427
+ console.error(`[electron-deck] loadURL("${source.url}") failed:`, err);
1428
+ this.surfaceLoadFailed(source, err);
1429
+ });
1430
+ else wc.loadFile(source.file).catch((err) => {
1431
+ console.error(`[electron-deck] loadFile("${source.file}") failed:`, err);
1432
+ this.surfaceLoadFailed(source, err);
1433
+ });
1434
+ }
1435
+ /**
1436
+ * D1 fix: load-failed 在 listener 注册之前到达时 buffer 到 pendingLoadFailed;
1437
+ * 第一个 listener 注册时 splice 消费整个队列。已有 listener 时直接 emit。
1438
+ */
1439
+ surfaceLoadFailed(source, error) {
1440
+ const listeners = this.fwListeners.get("load-failed");
1441
+ if (listeners && listeners.size > 0) {
1442
+ this.emitFrameworkEvent("load-failed", {
1443
+ source,
1444
+ error
1445
+ });
1446
+ return;
1447
+ }
1448
+ this.pendingLoadFailed.push({
1449
+ source,
1450
+ error
1451
+ });
1452
+ }
1453
+ bindWireTransport() {
1454
+ const wireOpts = this.options.wireTransport;
1455
+ if (!wireOpts) return;
1456
+ const trustedWebContents = wireOpts.trustedWebContents ?? (() => this.trustSet.snapshot());
1457
+ const defaultSenderPolicy = wireOpts.trustedWebContents ? { isTrusted: (id) => {
1458
+ for (const wc of wireOpts.trustedWebContents()) if (wc.id === id) return true;
1459
+ return false;
1460
+ } } : { isTrusted: (id) => this.trustSet.isTrusted(id) };
1461
+ const senderPolicy = wireOpts.senderPolicy ?? defaultSenderPolicy;
1462
+ this.wireSenderPolicy = senderPolicy;
1463
+ this.controlBus = createControlBus({
1464
+ bus: this.bus,
1465
+ trustSet: this.trustSet,
1466
+ policy: this.capability.policy
1467
+ });
1468
+ const transport = new WireTransport({
1469
+ ipcMain: wireOpts.ipcMain,
1470
+ bus: this.bus,
1471
+ senderPolicy,
1472
+ trustedWebContents,
1473
+ invokeHost: (name, args, ctx) => {
1474
+ if (this.isPrivilegedCommandName(name)) return this.controlBus.dispatch(name, args, ctx);
1475
+ return this.ipc.invoke(`${HOST_CHANNEL_PREFIX}${name}`, ...args);
1476
+ },
1477
+ invokeSimulator: (name, args, _ctx) => this.ipc.invoke(`${SIMULATOR_CHANNEL_PREFIX}${name}`, ...args),
1478
+ declaredEvents: () => this.config.events ? this.config.events.map((ev) => ev.name) : []
1479
+ });
1480
+ transport.start();
1481
+ this.wireTransport = transport;
1482
+ this.ensureSlotChannelsArmed();
1483
+ this.registry.add({ dispose: () => {
1484
+ transport.dispose();
1485
+ } });
1486
+ }
1487
+ /**
1488
+ * The two-route boundary's privileged-name predicate (§A5-1.2 硬约束). A
1489
+ * PRIVILEGED command name — by convention `layout.*` — routes through the
1490
+ * grant-gated {@link ControlBus} (`controlBus.dispatch`). Ordinary domain
1491
+ * APIs (any other name) stay on the un-gated declarative `hostServices`
1492
+ * route. Privileged names MUST NOT be registered in `hostServices`.
1493
+ */
1494
+ isPrivilegedCommandName(name) {
1495
+ return name.startsWith("layout.");
1496
+ }
1497
+ /**
1498
+ * KA-4: warn ONCE about an invalid `keepAlive.max` (negative / non-integer /
1499
+ * NaN). Such a view is not keep-alive-managed (the group is skipped); warning
1500
+ * once avoids log spam when many views share the same bad config.
1501
+ */
1502
+ warnedInvalidKeepAliveMax = false;
1503
+ warnInvalidKeepAliveMax(max) {
1504
+ if (this.warnedInvalidKeepAliveMax) return;
1505
+ this.warnedInvalidKeepAliveMax = true;
1506
+ console.warn(`[electron-deck] runtime.view keepAlive.max must be a non-negative integer (got: ${max}); the view is NOT keep-alive-managed (no eviction).`);
1507
+ }
1508
+ constructWindow(opts, autoTrust, deferLoad = false) {
1509
+ const electron = this.options.electron;
1510
+ if (!electron) throw new Error("DeckApp.constructWindow called without electron injection");
1511
+ const browserOpts = {};
1512
+ if (opts.title !== void 0) browserOpts.title = opts.title;
1513
+ if (opts.width !== void 0) browserOpts.width = opts.width;
1514
+ if (opts.height !== void 0) browserOpts.height = opts.height;
1515
+ if (opts.modal !== void 0) browserOpts.modal = opts.modal;
1516
+ if (opts.preloadPath !== void 0) browserOpts.webPreferences = { preload: opts.preloadPath };
1517
+ if ("parent" in opts && opts.parent !== void 0) browserOpts.parent = opts.parent;
1518
+ const win = new electron.BrowserWindow(browserOpts);
1519
+ this.trackedWindows.add(win);
1520
+ const wc = win.webContents;
1521
+ const windowScope = this.rootScope.child();
1522
+ windowScope.own(() => {
1523
+ if (!win.isDestroyed()) win.destroy();
1524
+ });
1525
+ this.lifetimeShadow.set(wc, {
1526
+ window: win,
1527
+ windowScope
1528
+ });
1529
+ this.windowSubstrates.set(wc, this.createWindowSubstrate(win, windowScope));
1530
+ win.on("closed", () => {
1531
+ this.handleSubWindowClosed(win, wc);
1532
+ });
1533
+ if (autoTrust) {
1534
+ this.admitTrust(wc, windowScope);
1535
+ this._notifyBackendTrusted(win.webContents);
1536
+ this.bindNavigationGrantReset(wc);
1537
+ }
1538
+ this.buildDeckWindow(win, wc, windowScope);
1539
+ this.armSubWindowCloseMachine(win, wc);
1540
+ if (!deferLoad) this.safeLoad(win.webContents, opts.source);
1541
+ return win;
1542
+ }
1543
+ /**
1544
+ * P3 — register an EXTERNALLY-created window into the framework so
1545
+ * `runtime.view().placeIn(win)` works for it: a `rootScope.child()`
1546
+ * windowScope, a per-window {@link ViewSubstrate}, and the TRUST lifecycle —
1547
+ * mirroring {@link constructWindow}'s registration, but for a window the host
1548
+ * built (e.g. under `ownsWindows:true`). The framework holds NO windowScope /
1549
+ * substrate / trust for such a window until this call.
1550
+ *
1551
+ * Ordering MIRRORS the framework's own windows (constructWindow / main-window):
1552
+ * 1) build the windowScope (child of rootScope so shutdown cascades it),
1553
+ * 2) build + register the per-window substrate (so placeIn can resolve it),
1554
+ * 3) for `ownership:'transfer'` ONLY, OWN `() => win.destroy()` on the
1555
+ * windowScope so app shutdown destroys the window; for `'observe'` the host
1556
+ * keeps lifetime control (the framework never destroys it),
1557
+ * 4) arm trust+grant+slot-token revocation as the FIRST `'closed'` listener via
1558
+ * `prependListener` (codex R2 — runs before any external `'closed'` listener),
1559
+ * 5) `admitTrust` AFTER the revoke listener is registered (constructWindow order).
1560
+ *
1561
+ * Idempotent by webContents identity: a second adopt of the same window returns
1562
+ * the existing registration. Throws if the window/webContents is destroyed.
1563
+ */
1564
+ adoptWindow(win, opts) {
1565
+ const wc = win.webContents;
1566
+ if (win.isDestroyed?.() || wc.isDestroyed?.()) throw new Error("runtime.windows.adopt: window is already destroyed");
1567
+ const existing = this.adoptedWindows.get(wc);
1568
+ if (existing) return existing;
1569
+ if (this.windowSubstrates.has(wc)) throw new Error("runtime.windows.adopt: window is already framework-tracked (cannot adopt a framework-owned window)");
1570
+ const windowScope = this.rootScope.child();
1571
+ if (opts?.ownership === "transfer") windowScope.own(() => {
1572
+ if (!win.isDestroyed()) win.destroy();
1573
+ });
1574
+ this.windowSubstrates.set(wc, this.createWindowSubstrate(win, windowScope));
1575
+ const adoptedWcId = wc.id;
1576
+ const revoke = () => {
1577
+ this.revokeWindowTrust(windowScope);
1578
+ this.revokeSlotTokensForWc(adoptedWcId);
1579
+ };
1580
+ if (typeof win.prependListener === "function") win.prependListener("closed", revoke);
1581
+ else win.on("closed", revoke);
1582
+ this.admitTrust(wc, windowScope);
1583
+ this.bindNavigationGrantReset(wc);
1584
+ this.buildDeckWindow(win, wc, windowScope);
1585
+ this.armSubWindowCloseMachine(win, wc);
1586
+ windowScope.own(() => {
1587
+ this.windowSubstrates.delete(wc);
1588
+ this.adoptedWindows.delete(wc);
1589
+ this.revokeSlotTokensForWc(adoptedWcId);
1590
+ });
1591
+ let disposed = false;
1592
+ const registration = { dispose: () => {
1593
+ if (disposed) return;
1594
+ disposed = true;
1595
+ this.revokeWindowTrust(windowScope);
1596
+ this.revokeSlotTokensForWc(adoptedWcId);
1597
+ windowScope.close().catch((e) => {
1598
+ console.error("[electron-deck] adopted windowScope.close() failed:", e);
1599
+ });
1600
+ } };
1601
+ this.adoptedWindows.set(wc, registration);
1602
+ return registration;
1603
+ }
1604
+ /**
1605
+ * unified-lifetime P1b: synchronously revoke BOTH trust leases AND capability
1606
+ * grants for every wc admitted under `windowScope` (the window's control wc +
1607
+ * any siblings like the toolbar wc). Called from the window's 'closed' handler
1608
+ * so both authorizations are gone the instant the window is destroyed —
1609
+ * matching the old synchronous `deleteEntry` timing and closing every
1610
+ * async-cascade race (a destroyed wc can never be observed trusted OR granted
1611
+ * by a later shutdown/beforeClose, nor by a NEW window that reuses the same
1612
+ * wc.id before the async scope cascade revokes it). The leases are ALSO owned
1613
+ * by their wcScope, so the async `windowScope.close()` still runs (it disposes
1614
+ * the already-disposed leases idempotently, plus win.destroy + wcRecords
1615
+ * cleanup) and remains the teardown/partial-init fallback for paths where no
1616
+ * 'closed' fires (cleanupOnError, shutdown of a never-closed window).
1617
+ * `lease.dispose()` is synchronous (trustSet ref-count--) and one-shot, so no
1618
+ * double-decrement; `revokeBySenderId` is likewise idempotent, so the grant's
1619
+ * own async `senderScope.on('closed')` revoke later becomes a no-op.
1620
+ */
1621
+ revokeWindowTrust(windowScope) {
1622
+ for (const [wc, rec] of this.wcRecords) if (rec.windowScope === windowScope) {
1623
+ for (const lease of rec.leases) lease.dispose();
1624
+ this.capability.revokeBySenderId(wc.id);
1625
+ }
1626
+ }
1627
+ /**
1628
+ * Arm the wire's slot-token inbound channels (idempotent). P5 eager-arm:
1629
+ * called once at framework start() (bindWireTransport, right after
1630
+ * transport.start()) so a layout client can subscribe before the first
1631
+ * anchored placeIn; the anchored `placeIn` call site is now a redundant no-op
1632
+ * (kept for safety). No-op when there's no wire transport (main-internal-only
1633
+ * builds can't serve a renderer slot anyway).
1634
+ */
1635
+ ensureSlotChannelsArmed() {
1636
+ this.wireTransport?.armSlotChannels((senderId, slotToken, placement) => this.handleSlotPlace(senderId, slotToken, placement), (senderId) => this.handleLayoutSubscribe(senderId));
1637
+ }
1638
+ /**
1639
+ * slot-token apply path (`__electron-deck:place`). The token is the credential:
1640
+ * look it up, verify the sender IS the wc the token was granted to (anti-spoof),
1641
+ * validate the placement, then apply. Any failure → DROP (silent).
1642
+ */
1643
+ handleSlotPlace(senderId, slotToken, placement) {
1644
+ const entry = this.slotTokens.get(slotToken);
1645
+ if (!entry) return;
1646
+ if (entry.authorizedWcId !== senderId) return;
1647
+ if (!isValidPlacement(placement)) return;
1648
+ entry.apply(placement);
1649
+ }
1650
+ /**
1651
+ * Per-wc layout-subscribe replay (`__electron-deck:layout-subscribe`). When the
1652
+ * authorized control wc (re)subscribes (e.g. after reload), re-push every grant
1653
+ * it owns so it can re-bind its DOM slots. A DIFFERENT wc's subscribe never
1654
+ * receives another wc's grants.
1655
+ */
1656
+ handleLayoutSubscribe(senderId) {
1657
+ for (const entry of this.slotTokens.values()) if (entry.authorizedWcId === senderId) entry.resend();
1658
+ }
1659
+ /** Drop every slot token authorized to `wcId` (window-close / shutdown hygiene). */
1660
+ revokeSlotTokensForWc(wcId) {
1661
+ for (const [token, entry] of this.slotTokens) if (entry.authorizedWcId === wcId) this.slotTokens.delete(token);
1662
+ }
1663
+ handleSubWindowClosed(win, wc) {
1664
+ this.trackedWindows.delete(win);
1665
+ this.windowSubstrates.delete(wc);
1666
+ this.revokeSlotTokensForWc(wc.id);
1667
+ const shadowEntry = this.lifetimeShadow.get(wc);
1668
+ if (shadowEntry) {
1669
+ this.lifetimeShadow.delete(wc);
1670
+ this.revokeWindowTrust(shadowEntry.windowScope);
1671
+ shadowEntry.windowScope.close();
1672
+ }
1673
+ for (const [key, w] of this.declaredWindows) if (w === win) {
1674
+ this.declaredWindows.delete(key);
1675
+ break;
1676
+ }
1677
+ this.backendTrustDisposables.get(wc)?.dispose();
1678
+ this.emitFrameworkEvent("window-closed", { window: win });
1679
+ }
1680
+ /**
1681
+ * v2 — notify the backend that the framework has edge-trusted one of its
1682
+ * (framework-built) webContents, so the backend can mirror it into the domain
1683
+ * trust set. The returned Disposable is disposed when THAT window closes
1684
+ * ({@link handleSubWindowClosed}) — honouring the contract "framework disposes
1685
+ * on untrust / window destroy" — and is also added to the registry as a
1686
+ * teardown safety net (e.g. the main window, which dies with the app). The
1687
+ * wrapper is one-shot so the two paths don't double-dispose.
1688
+ */
1689
+ _notifyBackendTrusted(wc) {
1690
+ const hook = this.options.backend?.onWindowTrusted;
1691
+ if (!hook) return;
1692
+ const d = hook(wc);
1693
+ if (!d) return;
1694
+ const key = wc;
1695
+ let disposed = false;
1696
+ const once = { dispose: () => {
1697
+ if (disposed) return;
1698
+ disposed = true;
1699
+ this.backendTrustDisposables.delete(key);
1700
+ d.dispose();
1701
+ } };
1702
+ this.backendTrustDisposables.set(key, once);
1703
+ this.registry.add(once);
1704
+ }
1705
+ emitFrameworkEvent(event, payload) {
1706
+ const set = this.fwListeners.get(event);
1707
+ if (!set || set.size === 0) return;
1708
+ for (const fn of Array.from(set)) try {
1709
+ fn(payload);
1710
+ } catch (err) {
1711
+ console.error(`[electron-deck] framework listener for "${event}" threw:`, err);
1712
+ }
1713
+ }
1714
+ async cleanupOnError() {
1715
+ this.lifecycle._force("cleanup");
1716
+ await this.runShutdownCleanup();
1717
+ this.lifecycle._force("destroy");
1718
+ this.lifecycle._force("quit");
1719
+ }
1720
+ async doShutdown() {
1721
+ if (this.lifecycle.current === "quit") return;
1722
+ if (this.lifecycle.current === "ready") {
1723
+ this.lifecycle.enter("drain");
1724
+ this.lifecycle.enter("cleanup");
1725
+ } else this.lifecycle._force("cleanup");
1726
+ await this.runShutdownCleanup();
1727
+ this.lifecycle._force("destroy");
1728
+ this.lifecycle._force("quit");
1729
+ if (!this.quitInitiated) try {
1730
+ this.options.electron?.app?.quit();
1731
+ } catch (e) {
1732
+ console.error("[electron-deck] app.quit() failed:", e);
1733
+ }
1734
+ }
1735
+ runShutdownCleanup() {
1736
+ if (this.cleanupPromise) return this.cleanupPromise;
1737
+ this.cleanupPromise = this.doShutdownCleanup();
1738
+ return this.cleanupPromise;
1739
+ }
1740
+ async doShutdownCleanup() {
1741
+ if (this.config.lifecycle?.beforeClose) {
1742
+ const timeoutMs = this.config.lifecycle.timeoutMs ?? 1e4;
1743
+ try {
1744
+ await runWithTimeout(this.config.lifecycle.beforeClose(), timeoutMs);
1745
+ } catch (e) {
1746
+ console.error("[electron-deck] lifecycle.beforeClose failed/timed out:", e);
1747
+ }
1748
+ }
1749
+ const onShutdown = this.options.backend?.onShutdown;
1750
+ if (onShutdown) try {
1751
+ await onShutdown.call(this.options.backend);
1752
+ } catch (e) {
1753
+ console.error("[electron-deck] backend.onShutdown failed:", e);
1754
+ }
1755
+ await this.rootScope.close();
1756
+ this.mainWindow = null;
1757
+ this.toolbarView = null;
1758
+ this.trackedWindows.clear();
1759
+ this.declaredWindows.clear();
1760
+ this.lifetimeShadow.clear();
1761
+ this.windowSubstrates.clear();
1762
+ this.adoptedWindows.clear();
1763
+ this.wcRecords.clear();
1764
+ this.slotTokens.clear();
1765
+ this.keepAliveGroups.clear();
1766
+ }
1767
+ buildRuntime() {
1768
+ const { ipc, registry, fwListeners } = this;
1769
+ const electronModule = this.options.electron;
1770
+ const wireIpcMain = this.options.wireTransport?.ipcMain;
1771
+ const electronUnavailable = (field) => {
1772
+ throw new Error(`runtime.${field} is unavailable in this build (no Electron)`);
1773
+ };
1774
+ const rawIpcMainUnavailable = () => {
1775
+ throw new Error("runtime.rawIpcMain is unavailable (no wireTransport.ipcMain injected)");
1776
+ };
1777
+ const context = {
1778
+ settings: { theme: "light" },
1779
+ theme: "light",
1780
+ _registry: this.registry,
1781
+ _senderPolicy: this.wireSenderPolicy ?? { isTrusted: () => false }
1782
+ };
1783
+ const trackedWindows = this.trackedWindows;
1784
+ const declaredWindows = this.declaredWindows;
1785
+ const getMainWindow = () => this.mainWindow;
1786
+ const getToolbarView = () => this.toolbarView;
1787
+ const constructWindow = (opts, autoTrust) => {
1788
+ const win = this.constructWindow(opts, autoTrust);
1789
+ this.emitFrameworkEvent("window-created", {
1790
+ window: win,
1791
+ role: "host"
1792
+ });
1793
+ return win;
1794
+ };
1795
+ const getMainDeckWindow = () => {
1796
+ if (!this.mainControlWc) return null;
1797
+ return this.windowRegistrations.get(this.mainControlWc)?.deckWindow ?? null;
1798
+ };
1799
+ const emitPendingFor = (event) => {
1800
+ if (event === "window-created") {
1801
+ if (this.pendingWindowCreated.length === 0) return;
1802
+ const queue = this.pendingWindowCreated.splice(0);
1803
+ for (const item of queue) this.emitFrameworkEvent("window-created", {
1804
+ window: item.window,
1805
+ role: item.role
1806
+ });
1807
+ } else if (event === "load-failed") {
1808
+ if (this.pendingLoadFailed.length === 0) return;
1809
+ const queue = this.pendingLoadFailed.splice(0);
1810
+ for (const item of queue) this.emitFrameworkEvent("load-failed", {
1811
+ source: item.source,
1812
+ error: item.error
1813
+ });
1814
+ }
1815
+ };
1816
+ return {
1817
+ get electron() {
1818
+ if (!electronModule) return electronUnavailable("electron");
1819
+ return electronModule;
1820
+ },
1821
+ get mainWindow() {
1822
+ const mw = getMainWindow();
1823
+ if (!mw) return electronUnavailable("mainWindow");
1824
+ return mw;
1825
+ },
1826
+ get toolbarView() {
1827
+ return getToolbarView() ?? null;
1828
+ },
1829
+ ipc,
1830
+ get rawIpcMain() {
1831
+ if (!wireIpcMain) return rawIpcMainUnavailable();
1832
+ return wireIpcMain;
1833
+ },
1834
+ call: {
1835
+ simulator: async (name, ...args) => ipc.invoke(`${SIMULATOR_CHANNEL_PREFIX}${name}`, ...args),
1836
+ host: async (name, ...args) => ipc.invoke(`${HOST_CHANNEL_PREFIX}${name}`, ...args)
1837
+ },
1838
+ windows: {
1839
+ create: (opts) => {
1840
+ if (!electronModule) return electronUnavailable("windows.create");
1841
+ const wc = constructWindow(opts, opts.autoTrust ?? true).webContents;
1842
+ const reg = this.windowRegistrations.get(wc);
1843
+ if (!reg) throw new Error("runtime.windows.create: DeckWindow registration missing (internal invariant)");
1844
+ return reg.deckWindow;
1845
+ },
1846
+ get main() {
1847
+ return getMainDeckWindow();
1848
+ },
1849
+ get: (id) => {
1850
+ const w = declaredWindows.get(id);
1851
+ return w ? w : void 0;
1852
+ },
1853
+ all: () => {
1854
+ const result = [];
1855
+ for (const w of trackedWindows) if (!w.isDestroyed()) result.push(w);
1856
+ return result;
1857
+ },
1858
+ trust: (win) => {
1859
+ const wc = win.webContents;
1860
+ const tracked = this.lifetimeShadow.get(wc);
1861
+ if (tracked) {
1862
+ const lease = this.admitTrust(wc, tracked.windowScope);
1863
+ this.bindNavigationGrantReset(wc);
1864
+ return lease;
1865
+ }
1866
+ return this._trustWebContents(wc);
1867
+ },
1868
+ adopt: (win, opts) => {
1869
+ const w = win;
1870
+ return this.adoptWindow(w, opts);
1871
+ }
1872
+ },
1873
+ view: (opts) => {
1874
+ if (!electronModule) return electronUnavailable("view");
1875
+ let displayScope = this.rootScope;
1876
+ if (opts.scope !== void 0) {
1877
+ const resolved = this.sessions.get(opts.scope);
1878
+ if (!resolved) throw new Error("runtime.view: `scope` must be a DeckSession from runtime.scopes.create() (a foreign/raw Scope is rejected)");
1879
+ displayScope = resolved;
1880
+ }
1881
+ const wcv = new electronModule.WebContentsView({});
1882
+ this.safeLoad(wcv.webContents, opts.source);
1883
+ const viewId = `view:${++this.viewSeq}`;
1884
+ let rootCloseReg = null;
1885
+ let rootCloseDisarmed = false;
1886
+ const disarmRootClose = () => {
1887
+ rootCloseDisarmed = true;
1888
+ rootCloseReg?.dispose();
1889
+ rootCloseReg = null;
1890
+ };
1891
+ if (displayScope === this.rootScope) rootCloseReg = this.rootScope.own(() => {
1892
+ if (rootCloseDisarmed) return;
1893
+ const wc = wcv.webContents;
1894
+ if (wc && !wc.isDestroyed()) wc.close?.();
1895
+ });
1896
+ const closeNativeWc = () => {
1897
+ const wc = wcv.webContents;
1898
+ if (wc && !wc.isDestroyed()) wc.close?.();
1899
+ };
1900
+ const nativeView = {
1901
+ ref: { id: viewId },
1902
+ setBounds: (b) => wcv.setBounds(b),
1903
+ destroy: closeNativeWc,
1904
+ webContents: wcv.webContents,
1905
+ capturePage: () => {
1906
+ const wc = wcv.webContents;
1907
+ if (!wc || wc.isDestroyed?.()) return Promise.reject(/* @__PURE__ */ new Error("ViewHandle.capturePage: native view destroyed"));
1908
+ return wc.capturePage();
1909
+ }
1910
+ };
1911
+ const keepAlive = opts.keepAlive;
1912
+ const keepAliveMaxValid = keepAlive ? Number.isInteger(keepAlive.max) && keepAlive.max >= 0 : false;
1913
+ if (keepAlive && !keepAliveMaxValid) this.warnInvalidKeepAliveMax(keepAlive.max);
1914
+ const groupKey = keepAlive && keepAliveMaxValid ? `lru:${keepAlive.max}` : null;
1915
+ const keepAliveGroup = () => {
1916
+ let g = this.keepAliveGroups.get(groupKey);
1917
+ if (!g) {
1918
+ g = {
1919
+ hidden: [],
1920
+ handles: /* @__PURE__ */ new Map()
1921
+ };
1922
+ this.keepAliveGroups.set(groupKey, g);
1923
+ }
1924
+ return g;
1925
+ };
1926
+ const removeFromKeepAliveGroup = (id) => {
1927
+ if (!groupKey) return;
1928
+ const group = this.keepAliveGroups.get(groupKey);
1929
+ if (!group) return;
1930
+ group.handles.delete(id);
1931
+ const i = group.hidden.indexOf(id);
1932
+ if (i >= 0) group.hidden.splice(i, 1);
1933
+ if (group.handles.size === 0) this.keepAliveGroups.delete(groupKey);
1934
+ };
1935
+ const inner = createViewHandle({
1936
+ nativeView,
1937
+ scope: displayScope,
1938
+ onDispose: () => removeFromKeepAliveGroup(viewId)
1939
+ });
1940
+ let placedSubstrate = null;
1941
+ let slotToken;
1942
+ let placed = false;
1943
+ const mintSlotToken = (controlWc, anchor) => {
1944
+ this.ensureSlotChannelsArmed();
1945
+ const authorizedWcId = controlWc.id;
1946
+ const token = randomUUID();
1947
+ slotToken = token;
1948
+ ++this.slotSeq;
1949
+ const grant = {
1950
+ viewId,
1951
+ slotId: anchor,
1952
+ slotToken: token
1953
+ };
1954
+ const resend = () => {
1955
+ controlWc.send(DeckChannel.SlotGrant, grant);
1956
+ };
1957
+ this.slotTokens.set(token, {
1958
+ viewId,
1959
+ slotId: anchor,
1960
+ authorizedWcId,
1961
+ resend,
1962
+ apply: (p) => {
1963
+ hostHandle.applyPlacement(p);
1964
+ }
1965
+ });
1966
+ resend();
1967
+ };
1968
+ const hostHandle = {
1969
+ placeIn: (win, placeOpts) => {
1970
+ if (placed) throw new Error("DeckViewHandle.placeIn: view already placed — use moveTo() to migrate");
1971
+ const controlWc = this.resolveWindowArg(win).webContents;
1972
+ const wc = controlWc;
1973
+ const substrate = this.windowSubstrates.get(wc);
1974
+ if (!substrate) throw new Error("runtime.view().placeIn: window is not framework-tracked");
1975
+ substrate.registerView(viewId, wcv);
1976
+ placedSubstrate = substrate;
1977
+ inner.placeIn({
1978
+ compositor: substrate.compositor,
1979
+ windowScope: substrate.windowScope
1980
+ }, { zone: placeOpts.zone });
1981
+ placed = true;
1982
+ disarmRootClose();
1983
+ const anchor = placeOpts.anchor;
1984
+ if (typeof anchor === "string" && anchor.length > 0) mintSlotToken(controlWc, anchor);
1985
+ return hostHandle;
1986
+ },
1987
+ moveTo: async (win, moveOpts) => {
1988
+ const destWin = this.resolveWindowArg(win);
1989
+ const destControlWc = destWin.webContents;
1990
+ const destWc = destControlWc;
1991
+ const destSub = this.windowSubstrates.get(destWc);
1992
+ if (!destSub) throw new Error("runtime.view().moveTo: window is not framework-tracked");
1993
+ const srcSub = placedSubstrate;
1994
+ destSub.registerView(viewId, wcv);
1995
+ try {
1996
+ await inner.moveTo({
1997
+ compositor: destSub.compositor,
1998
+ windowScope: destSub.windowScope
1999
+ }, {
2000
+ zone: moveOpts.zone,
2001
+ rehome: moveOpts.rehome
2002
+ });
2003
+ } catch (e) {
2004
+ try {
2005
+ if (!destWin.isDestroyed()) {
2006
+ const destChildren = destWin.contentView.children;
2007
+ if (destChildren == null || destChildren.includes(wcv)) destWin.contentView.removeChildView(wcv);
2008
+ }
2009
+ } catch (cleanupErr) {
2010
+ console.error("[electron-deck] moveTo dest detach failed (original error rethrown):", cleanupErr);
2011
+ }
2012
+ try {
2013
+ destSub.unregisterView(viewId);
2014
+ } catch (cleanupErr) {
2015
+ console.error("[electron-deck] moveTo dest unregister failed (original error rethrown):", cleanupErr);
2016
+ }
2017
+ throw e;
2018
+ }
2019
+ if (srcSub && srcSub !== destSub) srcSub.unregisterView(viewId);
2020
+ placedSubstrate = destSub;
2021
+ if (groupKey) {
2022
+ const group = this.keepAliveGroups.get(groupKey);
2023
+ if (group) {
2024
+ const hi = group.hidden.indexOf(viewId);
2025
+ if (hi >= 0) group.hidden.splice(hi, 1);
2026
+ }
2027
+ }
2028
+ if (slotToken) {
2029
+ this.slotTokens.delete(slotToken);
2030
+ slotToken = void 0;
2031
+ }
2032
+ const anchor = moveOpts.anchor;
2033
+ if (typeof anchor === "string" && anchor.length > 0) mintSlotToken(destControlWc, anchor);
2034
+ },
2035
+ applyPlacement: (p) => {
2036
+ inner.applyPlacement(p);
2037
+ if (groupKey) {
2038
+ const group = keepAliveGroup();
2039
+ const dropFromHidden = () => {
2040
+ const i = group.hidden.indexOf(viewId);
2041
+ if (i >= 0) group.hidden.splice(i, 1);
2042
+ };
2043
+ if (p.visible) dropFromHidden();
2044
+ else {
2045
+ if (!group.hidden.includes(viewId)) group.hidden.push(viewId);
2046
+ while (keepAlive && group.hidden.length > keepAlive.max) {
2047
+ const victimId = group.hidden.shift();
2048
+ const victim = group.handles.get(victimId);
2049
+ if (victim) victim.dispose();
2050
+ }
2051
+ }
2052
+ }
2053
+ return hostHandle;
2054
+ },
2055
+ dispose: async () => {
2056
+ await inner.dispose();
2057
+ disarmRootClose();
2058
+ closeNativeWc();
2059
+ placedSubstrate?.unregisterView(viewId);
2060
+ if (slotToken) this.slotTokens.delete(slotToken);
2061
+ removeFromKeepAliveGroup(viewId);
2062
+ },
2063
+ get webContents() {
2064
+ return inner.webContents;
2065
+ },
2066
+ bounds: () => inner.bounds(),
2067
+ capturePage: () => inner.capturePage()
2068
+ };
2069
+ if (groupKey) keepAliveGroup().handles.set(viewId, hostHandle);
2070
+ if (displayScope !== this.rootScope) displayScope.own(() => hostHandle.dispose().catch((e) => {
2071
+ console.error("[electron-deck] view session-scope dispose failed:", e);
2072
+ }));
2073
+ return hostHandle;
2074
+ },
2075
+ scopes: { create: () => {
2076
+ const scope = this.rootScope.child();
2077
+ const session = {
2078
+ reset: () => scope.reset(),
2079
+ dispose: () => scope.close()
2080
+ };
2081
+ this.sessions.set(session, scope);
2082
+ return session;
2083
+ } },
2084
+ grants: { issue: (controlWc, opts) => {
2085
+ const wc = controlWc;
2086
+ const rec = this.wcRecords.get(wc);
2087
+ if (!rec) throw new Error("runtime.grants.issue: webContents is not trusted (no wcScope to bind the grant to)");
2088
+ const targetScope = opts.targetScope ? this.sessions.get(opts.targetScope) : void 0;
2089
+ return this.capability.issue({
2090
+ senderId: wc.id,
2091
+ senderScope: rec.wcScope,
2092
+ targetScope,
2093
+ commands: new Set(opts.commands)
2094
+ });
2095
+ } },
2096
+ layout: { command: (name, handler) => {
2097
+ if (!name.startsWith("layout.")) throw new Error(`runtime.layout.command: privileged command names must start with "layout." (got: ${name})`);
2098
+ return this.controlBus.command(name, handler);
2099
+ } },
2100
+ context,
2101
+ on: (event, listener) => {
2102
+ let set = fwListeners.get(event);
2103
+ const isFirst = !set;
2104
+ if (!set) {
2105
+ set = /* @__PURE__ */ new Set();
2106
+ fwListeners.set(event, set);
2107
+ }
2108
+ const cast = listener;
2109
+ set.add(cast);
2110
+ if (isFirst) emitPendingFor(event);
2111
+ const ref = set;
2112
+ return { dispose: () => {
2113
+ ref.delete(cast);
2114
+ } };
2115
+ },
2116
+ add: (d) => registry.add(d)
2117
+ };
2118
+ }
2119
+ };
2120
+ /**
2121
+ * Validate an inbound `__electron-deck:place` placement before applying it.
2122
+ * `{visible:false}` is always OK; `{visible:true, bounds:{x,y,width,height}}`
2123
+ * requires width≥0 && height≥0 — x/y may be ANY finite number (negative origin
2124
+ * is legitimate for scroll-follow). Anything else is rejected.
2125
+ */
2126
+ function isValidPlacement(placement) {
2127
+ if (placement === null || typeof placement !== "object") return false;
2128
+ const p = placement;
2129
+ if (p.visible === false) return true;
2130
+ if (p.visible !== true) return false;
2131
+ const b = p.bounds;
2132
+ if (b === null || typeof b !== "object") return false;
2133
+ const { x, y, width, height } = b;
2134
+ if (typeof x !== "number" || !Number.isFinite(x)) return false;
2135
+ if (typeof y !== "number" || !Number.isFinite(y)) return false;
2136
+ if (typeof width !== "number" || !Number.isFinite(width) || width < 0) return false;
2137
+ if (typeof height !== "number" || !Number.isFinite(height) || height < 0) return false;
2138
+ return true;
2139
+ }
2140
+ async function runWithTimeout(work, timeoutMs) {
2141
+ let timer;
2142
+ const timeout = new Promise((_, rej) => {
2143
+ timer = setTimeout(() => rej(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
2144
+ });
2145
+ try {
2146
+ return await Promise.race([Promise.resolve(work), timeout]);
2147
+ } finally {
2148
+ if (timer !== void 0) clearTimeout(timer);
2149
+ }
2150
+ }
2151
+ //#endregion
2152
+ //#region src/electron-deck.ts
2153
+ /**
2154
+ * `electronDeck(config, options?)` 是 framework 唯一入口(见 README §3)。
2155
+ *
2156
+ * - Invalid config → reject `TypeError`,phase 不前进
2157
+ * - Valid config → 装配 runtime + 调 `config.setup(runtime)` await 完成 →
2158
+ * resolve。Phase 2 不接 Electron,resolve 后 framework 仍持有运行时;
2159
+ * Phase 4 接 Electron app lifecycle 后,由 Electron event loop 撑住进程,
2160
+ * `electronDeck()` 同样 resolve(host 的 main 文件不需 await 阻塞)。
2161
+ *
2162
+ * backend host(如 devtools)走 `electronDeck({ backend })`——backend 是
2163
+ * {@link DeckConfig} 的字段,不再需要空 `{}` + options。
2164
+ *
2165
+ * `options` 是测试 / 非 Electron 环境用的注入点(见 {@link DeckOptions})。
2166
+ * 生产路径不传 options:framework `await import('electron')` 取真 `ipcMain` /
2167
+ * `BrowserWindow` / `WebContentsView`。
2168
+ */
2169
+ async function electronDeck(config, options) {
2170
+ validateConfig(config);
2171
+ const resolved = await resolveAppOptions(options);
2172
+ if (config.backend) resolved.backend = config.backend;
2173
+ await new DeckApp(config, resolved).start();
2174
+ }
2175
+ /**
2176
+ * Synchronous launch handle for `electronDeck`.
2177
+ *
2178
+ * `electronDeck(config)` is `async` and internally `await app.start()` →
2179
+ * `await app.whenReady()`. A host ESM main entry doing `await electronDeck(config)`
2180
+ * SUSPENDS module evaluation on the whenReady gate — but Electron's `ready` only
2181
+ * fires once module evaluation finishes, so the gate never resolves: HARD DEADLOCK.
2182
+ *
2183
+ * `startElectronDeck` returns a plain handle SYNCHRONOUSLY (NOT a thenable), so a
2184
+ * host's top-level `await handle.ready` never sits on the whenReady gate. Assembly
2185
+ * still runs STRICTLY AFTER `app.whenReady()` resolves (gating intact inside
2186
+ * `app.start()`); `handle.ready` resolves with the {@link Runtime}; `handle.dispose()`
2187
+ * tears the app down even if called before the in-flight start finished.
2188
+ *
2189
+ * Invalid config throws a `TypeError` synchronously (matching `electronDeck`'s
2190
+ * validate-first contract) — the error surfaces, never silently deadlocks.
2191
+ */
2192
+ function startElectronDeck(config, options) {
2193
+ validateConfig(config);
2194
+ let app = null;
2195
+ const startPromise = (async () => {
2196
+ const resolved = await resolveAppOptions(options);
2197
+ if (config.backend) resolved.backend = config.backend;
2198
+ app = new DeckApp(config, resolved);
2199
+ await app.start();
2200
+ return app;
2201
+ })();
2202
+ const ready = startPromise.then((a) => a.runtime);
2203
+ ready.catch(() => {});
2204
+ return {
2205
+ ready,
2206
+ async dispose() {
2207
+ const started = await startPromise.catch(() => null);
2208
+ if (started) await started.shutdown();
2209
+ else if (app) await app.shutdown();
2210
+ }
2211
+ };
2212
+ }
2213
+ /**
2214
+ * 把公共 `DeckOptions` 解析成 internal {@link DeckAppOptions} —— 在
2215
+ * 显式注入不足时 lazy `import('electron')` 兜底。
2216
+ *
2217
+ * 决策 matrix:
2218
+ * - `electron` + `ipcMain` 都显式注入 → 不 lazy import(测试 / production override)
2219
+ * - 任一缺失 → `await import('electron')`,用 imported module 兜底缺失字段
2220
+ *
2221
+ * vitest 下 `await import('electron')` 解析到安装包 entry stub(导出可执行路径
2222
+ * 字符串),所以 `ipcMain` / `BrowserWindow` 都是 undefined。我们检测到关键
2223
+ * 字段缺失会显式 reject,提示注入 options。
2224
+ */
2225
+ async function resolveAppOptions(opts) {
2226
+ if (opts?.electron && opts?.ipcMain) return buildAppOptions(opts.electron, opts.ipcMain, opts);
2227
+ let imported;
2228
+ try {
2229
+ imported = await import("electron");
2230
+ } catch (e) {
2231
+ throw new Error("electronDeck(): unable to load electron — pass options.electron / options.ipcMain for non-Electron environments. Underlying: " + String(e), { cause: e });
2232
+ }
2233
+ const m = imported;
2234
+ const resolvedElectron = opts?.electron ?? asMinimalElectron(m);
2235
+ const resolvedIpcMain = opts?.ipcMain ?? m.ipcMain;
2236
+ if (!resolvedIpcMain || !resolvedElectron.BrowserWindow || !resolvedElectron.WebContentsView) throw new Error("electronDeck(): loaded \"electron\" but it does not expose the main-process surface (ipcMain / BrowserWindow / WebContentsView). This typically means you are running outside an Electron main process (e.g. vitest under node). Pass options.electron and options.ipcMain to inject a fake.");
2237
+ return buildAppOptions(resolvedElectron, resolvedIpcMain, opts);
2238
+ }
2239
+ function asMinimalElectron(m) {
2240
+ return m;
2241
+ }
2242
+ function buildAppOptions(electron, ipcMain, opts) {
2243
+ const out = {
2244
+ electron,
2245
+ wireTransport: { ipcMain }
2246
+ };
2247
+ if (opts?.trustedWebContents) out.wireTransport.trustedWebContents = opts.trustedWebContents;
2248
+ if (opts?.senderPolicy) out.wireTransport.senderPolicy = opts.senderPolicy;
2249
+ return out;
2250
+ }
2251
+ /**
2252
+ * Pure validation:不依赖 Electron / IPC / 任何 framework state,可单独 unit
2253
+ * test。每一条检查对应 doc §3.3 字段约束。
2254
+ *
2255
+ * 验证语义保证:通过 validateConfig 的 config 不会在 framework Bind 阶段被拒。
2256
+ * 含 HostEvent 来源、source 互斥、map vs array 等所有"shape 就绪"检查。
2257
+ *
2258
+ * @internal exported for tests
2259
+ */
2260
+ function validateConfig(config) {
2261
+ if (config === null || typeof config !== "object") throw new TypeError("electronDeck(config): config must be an object");
2262
+ if (config.backend !== void 0) {
2263
+ const backend = config.backend;
2264
+ if (backend === null || typeof backend !== "object" || typeof backend.assemble !== "function") throw new TypeError("config.backend must be a RuntimeBackend (an object with an assemble() function)");
2265
+ }
2266
+ if (config.simulatorApis !== void 0) validateHandlerMap("simulatorApis", config.simulatorApis);
2267
+ if (config.hostServices !== void 0) validateHandlerMap("hostServices", config.hostServices);
2268
+ if (config.events !== void 0) {
2269
+ if (!Array.isArray(config.events)) throw new TypeError("events must be an array of HostEvent (from defineEvent())");
2270
+ for (const ev of config.events) if (!isHostEvent(ev)) throw new TypeError("events: every entry must be a HostEvent produced by defineEvent() — duck-typed shapes are rejected to avoid bind-time failures");
2271
+ const names = /* @__PURE__ */ new Set();
2272
+ for (const ev of config.events) {
2273
+ if (names.has(ev.name)) throw new Error(`events: duplicate HostEvent name "${ev.name}"`);
2274
+ names.add(ev.name);
2275
+ }
2276
+ }
2277
+ if (config.toolbar !== void 0) {
2278
+ const { source, preloadPath, height } = config.toolbar;
2279
+ if (source === null || typeof source !== "object") throw new TypeError("toolbar.source must be { url } or { file }");
2280
+ const hasUrl = "url" in source && typeof source.url === "string";
2281
+ const hasFile = "file" in source && typeof source.file === "string";
2282
+ if (hasUrl && hasFile) throw new TypeError("toolbar.source must be either { url } or { file }, not both");
2283
+ if (!hasUrl && !hasFile) throw new TypeError("toolbar.source must be { url } or { file }");
2284
+ if (typeof preloadPath !== "string" || preloadPath.length === 0) throw new TypeError("toolbar.preloadPath is required and must be a non-empty string");
2285
+ if (typeof height !== "number" || !Number.isFinite(height) || height <= 0) throw new TypeError("toolbar.height is required and must be a positive finite number");
2286
+ }
2287
+ }
2288
+ function validateHandlerMap(fieldName, value) {
2289
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${fieldName} must be an object of handlers`);
2290
+ for (const [name, handler] of Object.entries(value)) if (typeof handler !== "function") throw new TypeError(`${fieldName}["${name}"] must be a function`);
2291
+ }
2292
+ //#endregion
2293
+ export { WireTransport as a, InMemoryTypedIpcRegistry as c, createControlBus as i, EventBus as l, startElectronDeck as n, createCapabilityRegistry as o, validateConfig as r, createTrustSet as s, electronDeck as t, defineEvent as u };
2294
+
2295
+ //# sourceMappingURL=electron-deck-DfFKeFEC.js.map