@dimina-kit/devtools 0.4.0-dev.20260701083540 → 0.4.0-dev.20260702145840

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 (81) hide show
  1. package/dist/main/app/app.js +11 -0
  2. package/dist/main/app/max-listeners-diagnostic.d.ts +32 -0
  3. package/dist/main/app/max-listeners-diagnostic.js +63 -0
  4. package/dist/main/index.bundle.js +1657 -1158
  5. package/dist/main/ipc/bridge-router.d.ts +33 -4
  6. package/dist/main/ipc/bridge-router.js +200 -51
  7. package/dist/main/ipc/session-listener-bag.d.ts +41 -0
  8. package/dist/main/ipc/session-listener-bag.js +40 -0
  9. package/dist/main/ipc/simulator.js +5 -5
  10. package/dist/main/ipc/views.js +8 -19
  11. package/dist/main/services/automation/wait-active-page.d.ts +4 -2
  12. package/dist/main/services/elements-forward/index.js +19 -0
  13. package/dist/main/services/network-forward/index.js +13 -0
  14. package/dist/main/services/render-inspect/index.d.ts +9 -0
  15. package/dist/main/services/render-inspect/index.js +13 -1
  16. package/dist/main/services/simulator-storage/index.d.ts +9 -0
  17. package/dist/main/services/simulator-storage/index.js +26 -1
  18. package/dist/main/services/simulator-wxml/index.js +117 -11
  19. package/dist/main/services/views/apply-view-ops.d.ts +11 -0
  20. package/dist/main/services/views/apply-view-ops.js +22 -0
  21. package/dist/main/services/views/destroy-child-view.d.ts +9 -0
  22. package/dist/main/services/views/destroy-child-view.js +23 -0
  23. package/dist/main/services/views/host-toolbar-view.d.ts +21 -0
  24. package/dist/main/services/views/host-toolbar-view.js +249 -0
  25. package/dist/main/services/views/inject-when-ready.d.ts +41 -0
  26. package/dist/main/services/views/inject-when-ready.js +62 -0
  27. package/dist/main/services/views/native-simulator-devtools-host.d.ts +26 -0
  28. package/dist/main/services/views/native-simulator-devtools-host.js +484 -0
  29. package/dist/main/services/views/native-simulator-view.d.ts +28 -0
  30. package/dist/main/services/views/native-simulator-view.js +428 -0
  31. package/dist/main/services/views/overlay-panels-view.d.ts +29 -0
  32. package/dist/main/services/views/overlay-panels-view.js +150 -0
  33. package/dist/main/services/views/placement-reconciler.d.ts +73 -0
  34. package/dist/main/services/views/placement-reconciler.js +120 -0
  35. package/dist/main/services/views/placement-test-driver.d.ts +25 -0
  36. package/dist/main/services/views/placement-test-driver.js +48 -0
  37. package/dist/main/services/views/resolve-project-editor-target.d.ts +13 -0
  38. package/dist/main/services/views/resolve-project-editor-target.js +33 -0
  39. package/dist/main/services/views/view-manager.d.ts +28 -70
  40. package/dist/main/services/views/view-manager.js +66 -1514
  41. package/dist/main/services/views/workbench-view.d.ts +15 -0
  42. package/dist/main/services/views/workbench-view.js +170 -0
  43. package/dist/main/services/workbench-context.d.ts +10 -0
  44. package/dist/preload/windows/host-toolbar-runtime.cjs +6 -13
  45. package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
  46. package/dist/preload/windows/main.cjs +10 -1
  47. package/dist/preload/windows/main.cjs.map +2 -2
  48. package/dist/preload/windows/simulator.cjs +6 -3
  49. package/dist/preload/windows/simulator.cjs.map +2 -2
  50. package/dist/preload/windows/simulator.js +6 -3
  51. package/dist/render-host/render-inspect.js +35 -1
  52. package/dist/renderer/assets/index-ChF9V4r-.js +49 -0
  53. package/dist/renderer/assets/{input-DR7bfyQE.js → input-Cjk0wSEh.js} +2 -2
  54. package/dist/renderer/assets/ipc-transport-D5dKIti1.css +1 -0
  55. package/dist/renderer/assets/{ipc-transport-3Arg8jPC.js → ipc-transport-OZS-KmOW.js} +2 -2
  56. package/dist/renderer/assets/{popover-CPZX3MrT.js → popover-CQVvOe90.js} +2 -2
  57. package/dist/renderer/assets/{select-BxyPlVyJ.js → select-BQDXi5ih.js} +2 -2
  58. package/dist/renderer/assets/{settings-api-CtPPJF-F.js → settings-api-B6x2mhaD.js} +2 -2
  59. package/dist/renderer/assets/{settings-CG_OR03F.js → settings-tP3Px2KR.js} +2 -2
  60. package/dist/renderer/assets/{workbenchSettings-ClqCBGOa.js → workbenchSettings-Bim9ol9R.js} +2 -2
  61. package/dist/renderer/entries/main/index.html +6 -6
  62. package/dist/renderer/entries/popover/index.html +5 -5
  63. package/dist/renderer/entries/settings/index.html +5 -5
  64. package/dist/renderer/entries/workbench-settings/index.html +4 -4
  65. package/dist/service-host/sync-api-patch.js +33 -3
  66. package/dist/shared/bridge-channels.d.ts +17 -0
  67. package/dist/shared/bridge-channels.js +9 -0
  68. package/dist/shared/ipc-channels.d.ts +29 -14
  69. package/dist/shared/ipc-channels.js +16 -17
  70. package/dist/shared/ipc-schemas.d.ts +25 -17
  71. package/dist/shared/ipc-schemas.js +38 -22
  72. package/dist/shared/view-ids.d.ts +19 -0
  73. package/dist/shared/view-ids.js +25 -0
  74. package/dist/simulator/assets/{bridge-channels-BUQ5AbvJ.js → bridge-channels-zhsumfky.js} +2 -2
  75. package/dist/simulator/assets/{device-shell-CT3LRnTB.js → device-shell-CUl0ILfn.js} +2 -2
  76. package/dist/simulator/assets/{simulator-BizhGGE3.js → simulator-Dz8iGcgy.js} +5 -5
  77. package/dist/simulator/assets/{simulator-mini-app-BfNuDF3R.js → simulator-mini-app-Bt639XL_.js} +2 -2
  78. package/dist/simulator/simulator.html +2 -1
  79. package/package.json +4 -4
  80. package/dist/renderer/assets/index-HE9Xr9bQ.js +0 -49
  81. package/dist/renderer/assets/ipc-transport-Dt0jR7Ir.css +0 -1
@@ -12,18 +12,42 @@ import type { DebugTap } from '@dimina-kit/electron-deck/main';
12
12
  */
13
13
  /**
14
14
  * Render-side activity worth re-reading panel data on: a page's DOM mounted
15
- * (`domReady`) or the visible page changed (`activePage`). Panels that pull
16
- * from the active render guest (WXML/element-inspect) subscribe via
17
- * `BridgeRouterHandle.onRenderEvent` so they can refresh without polling.
15
+ * (`domReady`), the visible page changed (`activePage`), or the active page's
16
+ * DOM mutated in place (`domMutated`, from the render-guest MutationObserver).
17
+ * Panels that pull from the active render guest (WXML/element-inspect) subscribe
18
+ * via `BridgeRouterHandle.onRenderEvent` so they can refresh without polling.
18
19
  */
19
20
  export interface RenderEvent {
20
- kind: 'domReady' | 'activePage';
21
+ kind: 'domReady' | 'activePage' | 'domMutated';
21
22
  appId: string;
22
23
  bridgeId: string;
23
24
  /** activePage only: the now-visible page's route (bare pagePath), if known.
24
25
  * Lets the current-page panel push the route without a separate lookup. */
25
26
  pagePath?: string;
26
27
  }
28
+ /**
29
+ * Point-in-time counts of every resource class RouterState owns. Leak coverage
30
+ * (unit + e2e) asserts EXACT equality of this ledger around a churn cycle —
31
+ * coarse memory sampling cannot see a leaked listener or a stale map entry, so
32
+ * the owner of the bookkeeping reports precise counts instead.
33
+ */
34
+ export interface BridgeResourceCensus {
35
+ appSessions: number;
36
+ pageSessions: number;
37
+ serviceWcBindings: number;
38
+ /** Distinct live simulator webContents currently hosting ≥1 session. */
39
+ simulatorWcs: number;
40
+ /** Total session memberships across all simulator wcs. */
41
+ simulatorWcBindings: number;
42
+ renderWcBindings: number;
43
+ pendingApiCalls: number;
44
+ /**
45
+ * `listenerCount('destroyed')` per unique live simulator wc (keyed by wc id).
46
+ * One teardown hook per live session — a count above the hosted-session count
47
+ * is the one-dead-listener-per-soft-reload leak class.
48
+ */
49
+ simulatorDestroyedListeners: Record<number, number>;
50
+ }
27
51
  export interface BridgeRouterHandle {
28
52
  /** Whether native-host mode is on (main is the source of truth). */
29
53
  isNativeHost(): boolean;
@@ -70,6 +94,11 @@ export interface BridgeRouterHandle {
70
94
  * error channel.
71
95
  */
72
96
  disposeSessionsForSimulator?(simulatorWcId: number): Promise<void>;
97
+ /**
98
+ * The router's resource ledger (see BridgeResourceCensus). Optional so the
99
+ * many partial BridgeRouterHandle test mocks don't each have to stub it.
100
+ */
101
+ census?(): BridgeResourceCensus;
73
102
  /**
74
103
  * The flag-gated debugTap (§7) over the bridge message stream. Exposed so a
75
104
  * hidden devtools panel / automation can read `.entries()` when
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
  import { BRIDGE_CHANNELS as C, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js';
5
5
  import { isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js';
6
6
  import { devtoolsPackageRoot } from '../utils/paths.js';
7
+ import { createSessionListenerBag } from './session-listener-bag.js';
7
8
  import { createDebugTap } from '@dimina-kit/electron-deck/main';
8
9
  import { startDiminaResourceServer } from '../services/dimina-resource-server.js';
9
10
  import { buildServiceHostSpawnUrl, createServiceHostWindow, navigateServiceHost, serviceHostSpec, } from '../windows/service-host-window/create.js';
@@ -147,7 +148,8 @@ export function installBridgeRouter(ctx) {
147
148
  const state = {
148
149
  appSessions: new Map(),
149
150
  pageSessions: new Map(),
150
- wcIdToAppSessionId: new Map(),
151
+ serviceWcIdToAppSessionId: new Map(),
152
+ simulatorWcIdToAppSessionIds: new Map(),
151
153
  wcIdToBridgeId: new Map(),
152
154
  pendingApiCalls: new Map(),
153
155
  pool: null,
@@ -299,8 +301,37 @@ export function installBridgeRouter(ctx) {
299
301
  return Promise.all(ids.map((id) => disposeAppSession(state, id))).then(() => { });
300
302
  },
301
303
  debugTap: state.debugTap,
304
+ census: () => {
305
+ const simulatorDestroyedListeners = {};
306
+ let simulatorWcBindings = 0;
307
+ for (const ids of state.simulatorWcIdToAppSessionIds.values())
308
+ simulatorWcBindings += ids.size;
309
+ for (const ap of state.appSessions.values()) {
310
+ const wc = ap.simulatorWc;
311
+ if (!wc.isDestroyed() && simulatorDestroyedListeners[wc.id] === undefined) {
312
+ simulatorDestroyedListeners[wc.id] = wc.listenerCount('destroyed');
313
+ }
314
+ }
315
+ return {
316
+ appSessions: state.appSessions.size,
317
+ pageSessions: state.pageSessions.size,
318
+ serviceWcBindings: state.serviceWcIdToAppSessionId.size,
319
+ simulatorWcs: state.simulatorWcIdToAppSessionIds.size,
320
+ simulatorWcBindings,
321
+ renderWcBindings: state.wcIdToBridgeId.size,
322
+ pendingApiCalls: state.pendingApiCalls.size,
323
+ simulatorDestroyedListeners,
324
+ };
325
+ },
302
326
  };
303
327
  ctx.bridge = bridgeHandle;
328
+ // e2e resource-census probe: Playwright's electronApp.evaluate() reads the
329
+ // router's ledger straight off this main-process global — no IPC surface
330
+ // needed. Test builds only.
331
+ if (process.env.NODE_ENV === 'test') {
332
+ ;
333
+ globalThis.__diminaResourceCensus = () => bridgeHandle.census?.();
334
+ }
304
335
  // Always-on guest console fan-out. Owns `ctx.guestConsole` (the sink the
305
336
  // consoleLog case below routes to) so that render-layer console output is
306
337
  // mirrored into the service host's own console — surfacing it in the embedded
@@ -322,8 +353,7 @@ export function installBridgeRouter(ctx) {
322
353
  const ap = state.appSessions.get(payload.appSessionId);
323
354
  if (!ap)
324
355
  return;
325
- const senderApp = appByWc(state, event.sender);
326
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
356
+ if (!senderBoundToSession(state, event.sender, ap))
327
357
  return;
328
358
  if (ap.pages.has(payload.bridgeId)) {
329
359
  ap.activeBridgeId = payload.bridgeId;
@@ -340,8 +370,7 @@ export function installBridgeRouter(ctx) {
340
370
  const ap = state.appSessions.get(payload.appSessionId);
341
371
  if (!ap)
342
372
  return;
343
- const senderApp = appByWc(state, event.sender);
344
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
373
+ if (!senderBoundToSession(state, event.sender, ap))
345
374
  return;
346
375
  ap.pageStack = payload.stack;
347
376
  };
@@ -366,8 +395,7 @@ export function installBridgeRouter(ctx) {
366
395
  // its tab (mirrors the default path's postMessage(pageUnload) eviction).
367
396
  if (payload.event === 'pageUnload' && ctx.appData) {
368
397
  const ap = state.appSessions.get(payload.appSessionId);
369
- const senderApp = appByWc(state, event.sender);
370
- if (ap && senderApp && senderApp.appSessionId === ap.appSessionId) {
398
+ if (ap && senderBoundToSession(state, event.sender, ap)) {
371
399
  ctx.appData.evictBridge(ap.appId, payload.bridgeId);
372
400
  }
373
401
  }
@@ -384,9 +412,11 @@ export function installBridgeRouter(ctx) {
384
412
  if (!target)
385
413
  return;
386
414
  // Only the app's service window or simulator window can issue dispose.
387
- const senderApp = appByWc(state, event.sender);
388
- if (senderApp && senderApp.appSessionId !== target.appSessionId) {
389
- console.warn(`[bridge-router] DISPOSE rejected: sender belongs to ${senderApp.appSessionId}, target ${target.appSessionId}`);
415
+ // (Unknown senders pass — lenient by design; a simulator WCV is authorized
416
+ // for every session it hosts, so an older session's own dispose stays
417
+ // valid while a newer spawn shares the wc.)
418
+ if (!senderBoundToSession(state, event.sender, target) && appByWc(state, event.sender)) {
419
+ console.warn(`[bridge-router] DISPOSE rejected: sender not bound to target ${target.appSessionId}`);
390
420
  return;
391
421
  }
392
422
  // AppData bridge eviction happens inside disposeAppSession (single
@@ -581,8 +611,9 @@ async function handleSpawn(state, ctx, event, opts) {
581
611
  pages: new Map(),
582
612
  activeBridgeId: null,
583
613
  poolEntryId,
584
- onServiceClosed: null,
585
614
  onServiceBoot: null,
615
+ listenerBag: createSessionListenerBag(),
616
+ registryHandle: null,
586
617
  };
587
618
  const rootPage = {
588
619
  bridgeId,
@@ -600,8 +631,8 @@ async function handleSpawn(state, ctx, event, opts) {
600
631
  state.appSessions.set(appSessionId, appSession);
601
632
  state.pageSessions.set(bridgeId, rootPage);
602
633
  appSession.pages.set(bridgeId, rootPage);
603
- bindWc(state.wcIdToAppSessionId, serviceWindow.webContents, appSessionId);
604
- bindWc(state.wcIdToAppSessionId, simulatorWc, appSessionId);
634
+ bindWc(state.serviceWcIdToAppSessionId, serviceWindow.webContents, appSessionId);
635
+ bindSimulatorWc(state.simulatorWcIdToAppSessionIds, simulatorWc, appSessionId);
605
636
  // Track the service-host webContents as a Connection (foundation.md §4.3) and
606
637
  // own this session's serviceWc→appSessionId binding on its CURRENT lifetime
607
638
  // segment. On a pooled-window REUSE, `disposeAppSession` calls
@@ -613,16 +644,15 @@ async function handleSpawn(state, ctx, event, opts) {
613
644
  // wc outlives individual app sessions — cross-connection state, §4.4 — so it
614
645
  // cannot hang off any single session's segment.)
615
646
  state.connections.acquire(serviceWindow.webContents).own(() => {
616
- if (state.wcIdToAppSessionId.get(appSession.serviceWc.id) === appSessionId) {
617
- state.wcIdToAppSessionId.delete(appSession.serviceWc.id);
647
+ if (state.serviceWcIdToAppSessionId.get(appSession.serviceWc.id) === appSessionId) {
648
+ state.serviceWcIdToAppSessionId.delete(appSession.serviceWc.id);
618
649
  }
619
650
  });
620
- ctx.registry.add(() => disposeAppSession(state, appSessionId));
651
+ appSession.registryHandle = ctx.registry.add(() => disposeAppSession(state, appSessionId));
621
652
  const onServiceClosed = () => {
622
653
  void disposeAppSession(state, appSessionId, { serviceAlreadyClosed: true });
623
654
  };
624
- appSession.onServiceClosed = onServiceClosed;
625
- serviceWindow.once('closed', onServiceClosed);
655
+ appSession.listenerBag.once(serviceWindow, 'closed', onServiceClosed);
626
656
  // The simulator WCV owns the app's UI lifetime. When it is destroyed —
627
657
  // project close (`views.disposeAll`/detach) or a DeviceShell respawn
628
658
  // (`attachNativeSimulator`, e.g. watcher hot reload) — the guest never gets
@@ -635,7 +665,7 @@ async function handleSpawn(state, ctx, event, opts) {
635
665
  const onSimulatorDestroyed = () => {
636
666
  void disposeAppSession(state, appSessionId);
637
667
  };
638
- simulatorWc.once('destroyed', onSimulatorDestroyed);
668
+ appSession.listenerBag.once(simulatorWc, 'destroyed', onSimulatorDestroyed);
639
669
  // The simulator WCV may have been torn down DURING this spawn's awaits
640
670
  // (loadAppConfig / resource server / service-host creation) — e.g. a project
641
671
  // close/switch that ran while we were mid-flight. In that case its 'destroyed'
@@ -702,8 +732,7 @@ async function handlePageOpen(state, event, opts) {
702
732
  if (!ap)
703
733
  throw new Error(`[bridge-router] PAGE_OPEN unknown appSession ${opts.appSessionId}`);
704
734
  // Only the simulator window owning the app can open additional pages.
705
- const senderApp = appByWc(state, event.sender);
706
- if (!senderApp || senderApp.appSessionId !== opts.appSessionId) {
735
+ if (!senderBoundToSession(state, event.sender, ap)) {
707
736
  throw new Error('[bridge-router] PAGE_OPEN rejected: caller not bound to app session');
708
737
  }
709
738
  const pagePath = normalizePagePath(opts.pagePath);
@@ -738,8 +767,7 @@ function handlePageClose(state, sender, payload) {
738
767
  const ap = state.appSessions.get(page.appSessionId);
739
768
  if (!ap)
740
769
  return;
741
- const senderApp = appByWc(state, sender);
742
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId) {
770
+ if (!senderBoundToSession(state, sender, ap)) {
743
771
  console.warn('[bridge-router] PAGE_CLOSE rejected: caller not bound to app session');
744
772
  return;
745
773
  }
@@ -749,8 +777,7 @@ function handlePageLifecycle(state, sender, payload) {
749
777
  const ap = state.appSessions.get(payload.appSessionId);
750
778
  if (!ap)
751
779
  return;
752
- const senderApp = appByWc(state, sender);
753
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
780
+ if (!senderBoundToSession(state, sender, ap))
754
781
  return;
755
782
  forwardToService(ap, {
756
783
  type: payload.event,
@@ -762,8 +789,7 @@ function handleNavCallback(state, sender, payload) {
762
789
  const ap = state.appSessions.get(payload.appSessionId);
763
790
  if (!ap)
764
791
  return;
765
- const senderApp = appByWc(state, sender);
766
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
792
+ if (!senderBoundToSession(state, sender, ap))
767
793
  return;
768
794
  const result = { errMsg: payload.errMsg };
769
795
  if (payload.ok) {
@@ -793,23 +819,50 @@ async function bootServiceHost(state, ap, ctx) {
793
819
  reportLogicLoadFailure(ap, ctx);
794
820
  return;
795
821
  }
796
- // serviceLoaded is flipped only when service responds with
797
- // `serviceResourceLoaded`; see handleContainerMsg. Setting it here would
798
- // race a per-page `resourceLoaded` ahead of the service-side handler.
799
- forwardToService(ap, makeLoadResource(ap, ap.pages.get(ap.appSessionId), 'service'));
822
+ // A root page absent from the compiled manifest — most commonly a page the
823
+ // developer deleted, then hot-reloaded to is not registered in logic.js.
824
+ // BOTH the service runtime AND the render runtime eager-`modRequire(pagePath)`
825
+ // it and throw `module <pagePath> not found`, which aborts the launch and
826
+ // leaves the simulator permanently blank. Gate BOTH sends and surface a
827
+ // WeChat-style diagnostic instead: the shell stays alive (an empty phone frame
828
+ // + a clear Console error) rather than a silent dead blank. Choosing a valid
829
+ // fallback page belongs at the renderer reload source (it owns the pagePath
830
+ // the render guest is spawned with); here we only refuse a load guaranteed to
831
+ // throw, so main and render never disagree about which page is live.
832
+ const rootPage = ap.pages.get(ap.appSessionId);
833
+ const rootMissing = !!rootPage && !pageInManifest(ap, rootPage.pagePath);
834
+ if (rootMissing) {
835
+ reportPageNotFound(ap, ctx, rootPage.pagePath);
836
+ }
837
+ else if (rootPage) {
838
+ // serviceLoaded is flipped only when service responds with
839
+ // `serviceResourceLoaded`; see handleContainerMsg. Setting it here would
840
+ // race a per-page `resourceLoaded` ahead of the service-side handler.
841
+ forwardToService(ap, makeLoadResource(ap, rootPage, 'service'));
842
+ }
800
843
  // Flush any render `loadResource` that arrived (renderHostReady) while
801
844
  // injection was in-flight — now that the bundle is confirmed present.
845
+ // `sendRenderLoadResource` refuses a page absent from the manifest, so a
846
+ // missing root never reaches the throwing render `modRequire`.
802
847
  for (const page of ap.pages.values()) {
803
- if (page.renderLoadPending) {
804
- page.renderLoadPending = false;
805
- sendRenderLoadResource(ap, page);
806
- }
848
+ if (!page.renderLoadPending)
849
+ continue;
850
+ page.renderLoadPending = false;
851
+ sendRenderLoadResource(ap, page);
807
852
  }
808
853
  }
809
854
  function sendRenderLoadResource(ap, page) {
810
- if (page.renderWc && !page.renderWc.isDestroyed()) {
811
- page.renderWc.send(C.TO_RENDER, { msg: makeLoadResource(ap, page, 'render') });
812
- }
855
+ if (!page.renderWc || page.renderWc.isDestroyed())
856
+ return;
857
+ // Single choke point for BOTH the boot flush above and the late
858
+ // `renderHostReady` direct-send in `routeFromRender`: never send a render load
859
+ // for a page absent from the compiled manifest. The render runtime
860
+ // eager-`modRequire`s the pagePath and would throw `module <pagePath> not
861
+ // found`, blanking the simulator (a page the developer deleted, then
862
+ // hot-reloaded to). bootServiceHost surfaces the one-shot diagnostic.
863
+ if (!pageInManifest(ap, page.pagePath))
864
+ return;
865
+ page.renderWc.send(C.TO_RENDER, { msg: makeLoadResource(ap, page, 'render') });
813
866
  }
814
867
  /** The compiled logic.js URL `injectLogicBundle` fetches (mode-dependent). */
815
868
  function logicBundleUrl(ap) {
@@ -856,6 +909,21 @@ function reportLogicLoadFailure(ap, ctx) {
856
909
  console.error(message);
857
910
  ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
858
911
  }
912
+ /** Whether `pagePath` still exists in the app's freshly compiled manifest. */
913
+ function pageInManifest(ap, pagePath) {
914
+ return ap.manifest.pages.includes(normalizePagePath(pagePath));
915
+ }
916
+ /**
917
+ * WeChat-devtools-style "page does not exist" diagnostic. Emitted (main log +
918
+ * guest Console) when a mount targets a pagePath that is not in the compiled
919
+ * manifest — most commonly a page the developer deleted, then hot-reloaded.
920
+ */
921
+ function reportPageNotFound(ap, ctx, pagePath) {
922
+ const message = `Page[${pagePath}] not found. May be caused by: 1. Forgetting to add page route in app.json. `
923
+ + '2. Invoking Page() in async task.';
924
+ console.error(`[bridge-router] ${message}`);
925
+ ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
926
+ }
859
927
  function maybeSendResourceLoaded(ap, page) {
860
928
  if (page.resourceLoadedSent || !ap.serviceLoaded || !page.renderLoaded)
861
929
  return;
@@ -977,6 +1045,20 @@ function handleContainerMsg(ap, page, msg, ctx, state) {
977
1045
  // Guarded + non-throwing: console capture must never break message routing.
978
1046
  ctx.guestConsole?.emit(msg.body);
979
1047
  break;
1048
+ case 'storageChanged':
1049
+ // Native-host SYNC storage liveness: the service-host's `setStorageSync`/etc.
1050
+ // write `localStorage` directly (no main round-trip), so they post the change
1051
+ // here for the Storage panel to stay live. Trust `ap.appId` (sender-resolved),
1052
+ // not any appId in the body. Guarded + non-throwing.
1053
+ ctx.onServiceStorageChanged?.(ap.appId, msg.body);
1054
+ break;
1055
+ case 'wxmlChanged':
1056
+ // Native-host WXML liveness: the render-guest MutationObserver posts this when
1057
+ // the active page's DOM mutated in place (setData). Surface it as a render
1058
+ // event so the WXML panel service re-pulls + pushes — same pipeline as
1059
+ // domReady/activePage. Trust `page.bridgeId` (sender-resolved), not the body.
1060
+ state.emitRenderEvent({ kind: 'domMutated', appId: ap.appId, bridgeId: page.bridgeId });
1061
+ break;
980
1062
  default:
981
1063
  break;
982
1064
  }
@@ -1245,11 +1327,13 @@ function handleApiResponse(state, sender, payload) {
1245
1327
  clearTimeout(pending.timer);
1246
1328
  return;
1247
1329
  }
1248
- // Only the simulator window bound to that app may respond. Validate BEFORE
1330
+ // Only a simulator window bound to the OWNING session may respond. Membership
1331
+ // (senderBoundToSession), not sender-resolution equality: during a soft-reload
1332
+ // overlap the shared simulator wc still answers the outgoing session's
1333
+ // in-flight calls, which a latest-wins comparison would drop. Validate BEFORE
1249
1334
  // mutating pending state so a spoofed/foreign response can't tear down a
1250
1335
  // live subscription.
1251
- const senderApp = appByWc(state, sender);
1252
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId) {
1336
+ if (!senderBoundToSession(state, sender, ap)) {
1253
1337
  console.warn('[bridge-router] API_RESPONSE rejected: sender not bound to app session');
1254
1338
  return;
1255
1339
  }
@@ -1380,9 +1464,20 @@ function pageFromMsg(state, ap, msg) {
1380
1464
  function appByWc(state, wc) {
1381
1465
  if (wc.isDestroyed())
1382
1466
  return undefined;
1383
- const appSessionId = state.wcIdToAppSessionId.get(wc.id);
1467
+ const appSessionId = state.serviceWcIdToAppSessionId.get(wc.id);
1384
1468
  if (appSessionId)
1385
1469
  return state.appSessions.get(appSessionId);
1470
+ // A simulator wc hosts several sessions during a soft reload; a message that
1471
+ // doesn't name its session belongs to the LATEST still-alive spawn (the Set
1472
+ // preserves spawn order).
1473
+ const hosted = state.simulatorWcIdToAppSessionIds.get(wc.id);
1474
+ if (hosted) {
1475
+ let latest;
1476
+ for (const id of hosted)
1477
+ latest = state.appSessions.get(id) ?? latest;
1478
+ if (latest)
1479
+ return latest;
1480
+ }
1386
1481
  const bridgeId = state.wcIdToBridgeId.get(wc.id);
1387
1482
  if (bridgeId) {
1388
1483
  const page = state.pageSessions.get(bridgeId);
@@ -1391,6 +1486,21 @@ function appByWc(state, wc) {
1391
1486
  }
1392
1487
  return undefined;
1393
1488
  }
1489
+ /**
1490
+ * Whether `sender` is authorized to control app session `ap`. A simulator WCV
1491
+ * is authorized for EVERY session it hosts (`simulatorWcIdToAppSessionIds` is
1492
+ * a membership set, so an older session's own control traffic stays valid
1493
+ * while a newer spawn boots on the same wc); any other sender (service host,
1494
+ * render guest) resolves one-to-one through `appByWc`.
1495
+ */
1496
+ function senderBoundToSession(state, sender, ap) {
1497
+ if (sender.isDestroyed())
1498
+ return false;
1499
+ const hosted = state.simulatorWcIdToAppSessionIds.get(sender.id);
1500
+ if (hosted)
1501
+ return hosted.has(ap.appSessionId);
1502
+ return appByWc(state, sender)?.appSessionId === ap.appSessionId;
1503
+ }
1394
1504
  function resolveAppByBridgeId(state, bridgeId) {
1395
1505
  if (state.appSessions.has(bridgeId))
1396
1506
  return state.appSessions.get(bridgeId);
@@ -1404,6 +1514,17 @@ function bindWc(map, wc, id) {
1404
1514
  return;
1405
1515
  map.set(wc.id, id);
1406
1516
  }
1517
+ /** Add a session to a simulator wc's hosted set (created on first bind). */
1518
+ function bindSimulatorWc(map, wc, id) {
1519
+ if (wc.isDestroyed())
1520
+ return;
1521
+ let hosted = map.get(wc.id);
1522
+ if (!hosted) {
1523
+ hosted = new Set();
1524
+ map.set(wc.id, hosted);
1525
+ }
1526
+ hosted.add(id);
1527
+ }
1407
1528
  function readBridgeId(msg) {
1408
1529
  return typeof msg.body?.bridgeId === 'string' ? msg.body.bridgeId : undefined;
1409
1530
  }
@@ -1428,6 +1549,15 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1428
1549
  if (!ap)
1429
1550
  return;
1430
1551
  state.appSessions.delete(appSessionId);
1552
+ // Remove this session's shutdown-fallback registry entry so the registry does
1553
+ // not accumulate one stale closure per respawn. Ordered AFTER the delete
1554
+ // above: dispose() runs the wrapped fn (disposeAppSession again), which
1555
+ // early-returns now that the session is gone — no recursion, no double-run.
1556
+ // (At whole-app shutdown the registry marks the entry released before calling
1557
+ // the fn, so this dispose() is a no-op then.)
1558
+ const registryHandle = ap.registryHandle;
1559
+ ap.registryHandle = null;
1560
+ void registryHandle?.dispose();
1431
1561
  state.appLifecycle.dispose(appSessionId);
1432
1562
  // Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the
1433
1563
  // page teardown below progressively empties (and finally clears).
@@ -1459,6 +1589,14 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1459
1589
  state.pageSessions.delete(page.bridgeId);
1460
1590
  }
1461
1591
  ap.pages.clear();
1592
+ // Detach every session-scoped hook on emitters that outlive this session —
1593
+ // the shared simulator WCV's 'destroyed' and the service window's 'closed' —
1594
+ // BEFORE the window is released/closed below: a pool-recycled window must
1595
+ // not re-trigger this session's teardown from a stale 'closed', and the
1596
+ // simulator wc must not accumulate one dead hook per soft reload until the
1597
+ // MaxListeners warning fires. Idempotent with teardown being triggered BY
1598
+ // one of these hooks (removing a fired once() is a no-op).
1599
+ ap.listenerBag.dispose();
1462
1600
  if (ap.poolEntryId !== null && state.pool && !opts.serviceAlreadyClosed) {
1463
1601
  // Soft reuse (foundation.md §4.3): the pooled service-host webContents keeps
1464
1602
  // its wc.id but is about to run a NEW app session. Reset its Connection
@@ -1468,13 +1606,10 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1468
1606
  // clean segment. The connection object stays alive (it's reused), so this is
1469
1607
  // reset (not close); a real destroy still closes it via its 'destroyed' hook.
1470
1608
  state.connections.reset(ap.serviceWc.id);
1471
- // Return the window to the pool instead of closing it. Detach the per-spawn
1472
- // listeners first so the pool resetting/recycling (or a later pool-side
1473
- // destroy) can't re-trigger disposeAppSession or boot this disposed session
1474
- // into the next spawn's recycled window.
1475
- if (ap.onServiceClosed && !ap.serviceWindow.isDestroyed()) {
1476
- ap.serviceWindow.removeListener('closed', ap.onServiceClosed);
1477
- }
1609
+ // Return the window to the pool instead of closing it. Detach the boot
1610
+ // listener first (the 'closed' hook already came off with the listener-bag
1611
+ // dispose above) so a stale did-finish-load can't boot this disposed
1612
+ // session into the next spawn's recycled window.
1478
1613
  if (ap.onServiceBoot && !ap.serviceWindow.isDestroyed()) {
1479
1614
  ap.serviceWindow.webContents.removeListener('did-finish-load', ap.onServiceBoot);
1480
1615
  }
@@ -1494,8 +1629,22 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1494
1629
  else if (!opts.serviceAlreadyClosed && !ap.serviceWindow.isDestroyed()) {
1495
1630
  ap.serviceWindow.close();
1496
1631
  }
1497
- state.wcIdToAppSessionId.delete(ap.serviceWc.id);
1498
- state.wcIdToAppSessionId.delete(ap.simulatorWc.id);
1632
+ // Value-checked unbind for the service wc: this delete runs after the
1633
+ // pool-release await above, so on the pool path the next spawn may have
1634
+ // already re-acquired the SAME window and rebound its wc id — only remove
1635
+ // an entry that still names THIS session.
1636
+ if (state.serviceWcIdToAppSessionId.get(ap.serviceWc.id) === appSessionId) {
1637
+ state.serviceWcIdToAppSessionId.delete(ap.serviceWc.id);
1638
+ }
1639
+ // The simulator wc's hosted set sheds only this session; sessions that are
1640
+ // still live on the shared wc (the soft-reload survivor) keep their
1641
+ // membership. The map entry goes away with the last session.
1642
+ const hosted = state.simulatorWcIdToAppSessionIds.get(ap.simulatorWc.id);
1643
+ if (hosted) {
1644
+ hosted.delete(appSessionId);
1645
+ if (hosted.size === 0)
1646
+ state.simulatorWcIdToAppSessionIds.delete(ap.simulatorWc.id);
1647
+ }
1499
1648
  // Only the local fallback server needs closing; the dev-server base is owned
1500
1649
  // by the workspace session, not this app session.
1501
1650
  if (ap.resourceServer) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Session-scoped listener bookkeeping. An app session hangs lifecycle hooks on
3
+ * emitters it does NOT own — the shared simulator WebContentsView, a pooled
4
+ * service-host window — and those emitters outlive the session (one simulator
5
+ * wc soft-reloads through many sessions). Every such hook is registered
6
+ * through this bag so the session's single teardown chokepoint can detach them
7
+ * all with one `dispose()`; a hook left behind survives as a dead listener on
8
+ * the shared emitter, growing by one per soft reload until Node's MaxListeners
9
+ * warning fires.
10
+ */
11
+ type Listener = (...args: unknown[]) => void;
12
+ /**
13
+ * Structural surface of the Node/Electron emitters the bag manages.
14
+ * `removeListener(event, fn)` also detaches a `once(event, fn)` registration:
15
+ * Node's once wrapper keeps a `.listener` back-reference to `fn` and
16
+ * removeListener matches through it. `isDestroyed` covers Electron objects
17
+ * (WebContents / BrowserWindow) whose methods throw once destroyed.
18
+ */
19
+ export interface BagEmitter {
20
+ on(event: string, listener: Listener): unknown;
21
+ once(event: string, listener: Listener): unknown;
22
+ removeListener(event: string, listener: Listener): unknown;
23
+ isDestroyed?(): boolean;
24
+ }
25
+ export interface SessionListenerBag {
26
+ /** Attach `fn` via `emitter.on` and track it for dispose. No-op after dispose. */
27
+ on(emitter: BagEmitter, event: string, fn: Listener): void;
28
+ /** Attach `fn` via `emitter.once` and track it for dispose. No-op after dispose. */
29
+ once(emitter: BagEmitter, event: string, fn: Listener): void;
30
+ /**
31
+ * Detach every tracked listener that may still be attached. Emitters that
32
+ * report `isDestroyed()` are skipped — their listeners died with them, and
33
+ * touching a destroyed Electron object throws. Removing a `once` that
34
+ * already fired is a no-op, so dispose is safe to call FROM one of the
35
+ * bag's own hooks (teardown triggered by the hook itself). Idempotent.
36
+ */
37
+ dispose(): void;
38
+ }
39
+ export declare function createSessionListenerBag(): SessionListenerBag;
40
+ export {};
41
+ //# sourceMappingURL=session-listener-bag.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Session-scoped listener bookkeeping. An app session hangs lifecycle hooks on
3
+ * emitters it does NOT own — the shared simulator WebContentsView, a pooled
4
+ * service-host window — and those emitters outlive the session (one simulator
5
+ * wc soft-reloads through many sessions). Every such hook is registered
6
+ * through this bag so the session's single teardown chokepoint can detach them
7
+ * all with one `dispose()`; a hook left behind survives as a dead listener on
8
+ * the shared emitter, growing by one per soft reload until Node's MaxListeners
9
+ * warning fires.
10
+ */
11
+ export function createSessionListenerBag() {
12
+ const records = [];
13
+ let disposed = false;
14
+ return {
15
+ on(emitter, event, fn) {
16
+ if (disposed)
17
+ return;
18
+ emitter.on(event, fn);
19
+ records.push({ emitter, event, fn });
20
+ },
21
+ once(emitter, event, fn) {
22
+ if (disposed)
23
+ return;
24
+ emitter.once(event, fn);
25
+ records.push({ emitter, event, fn });
26
+ },
27
+ dispose() {
28
+ if (disposed)
29
+ return;
30
+ disposed = true;
31
+ for (const { emitter, event, fn } of records) {
32
+ if (emitter.isDestroyed?.())
33
+ continue;
34
+ emitter.removeListener(event, fn);
35
+ }
36
+ records.length = 0;
37
+ },
38
+ };
39
+ }
40
+ //# sourceMappingURL=session-listener-bag.js.map
@@ -1,5 +1,5 @@
1
1
  import { ServiceHostChannel, SimulatorChannel, SimulatorCustomApiChannel } from '../../shared/ipc-channels.js';
2
- import { SimulatorAttachNativeSchema, SimulatorCustomApiInvokeSchema, SimulatorSetDeviceInfoSchema, SimulatorSetNativeBoundsSchema, } from '../../shared/ipc-schemas.js';
2
+ import { SimulatorAttachNativeSchema, SimulatorCustomApiInvokeSchema, SimulatorSetDeviceInfoSchema, SimulatorSoftReloadSchema, } from '../../shared/ipc-schemas.js';
3
3
  import { deviceInfoToHostEnv } from '../../shared/bridge-channels.js';
4
4
  import { validate } from '../utils/ipc-schema.js';
5
5
  import { IpcRegistry } from '../utils/ipc-registry.js';
@@ -8,13 +8,13 @@ export function registerSimulatorIpc(ctx) {
8
8
  .handle(SimulatorChannel.AttachNative, (_, ...args) => {
9
9
  const [simulatorUrl, simWidth] = validate(SimulatorChannel.AttachNative, SimulatorAttachNativeSchema, args);
10
10
  return ctx.views.attachNativeSimulator(simulatorUrl, simWidth);
11
+ })
12
+ .handle(SimulatorChannel.SoftReload, (_, ...args) => {
13
+ const [simulatorUrl] = validate(SimulatorChannel.SoftReload, SimulatorSoftReloadSchema, args);
14
+ return ctx.views.softReloadNativeSimulator(simulatorUrl);
11
15
  })
12
16
  .handle(SimulatorChannel.Detach, () => {
13
17
  ctx.views.detachSimulator();
14
- })
15
- .handle(SimulatorChannel.SetNativeBounds, (_, ...args) => {
16
- const [p] = validate(SimulatorChannel.SetNativeBounds, SimulatorSetNativeBoundsSchema, args);
17
- ctx.views.setNativeSimulatorViewBounds(p);
18
18
  })
19
19
  .handle(SimulatorChannel.SetDeviceInfo, (_, ...args) => {
20
20
  const [device] = validate(SimulatorChannel.SetDeviceInfo, SimulatorSetDeviceInfoSchema, args);
@@ -1,6 +1,6 @@
1
1
  import { ipcMain } from 'electron';
2
2
  import { ViewChannel } from '../../shared/ipc-channels.js';
3
- import { ViewBoundsSchema, HostToolbarAdvertiseHeightSchema, } from '../../shared/ipc-schemas.js';
3
+ import { PlacementSnapshotSchema, HostToolbarAdvertiseHeightSchema, } from '../../shared/ipc-schemas.js';
4
4
  import { validate } from '../utils/ipc-schema.js';
5
5
  import { IpcRegistry } from '../utils/ipc-registry.js';
6
6
  /**
@@ -20,24 +20,13 @@ import { IpcRegistry } from '../utils/ipc-registry.js';
20
20
  */
21
21
  export function registerViewsIpc(ctx) {
22
22
  const registry = new IpcRegistry(ctx.senderPolicy)
23
- .handle(ViewChannel.SimulatorDevtoolsBounds, (_event, ...args) => {
24
- const [bounds] = validate(ViewChannel.SimulatorDevtoolsBounds, ViewBoundsSchema, args);
25
- ctx.views.setSimulatorDevtoolsBounds(bounds);
26
- })
27
- // Embedded workbench editor: forward anchor (the MAIN renderer measures
28
- // the 'editor' dock slot rect → workbench WCV bounds). invoke, mirroring
29
- // SimulatorDevtoolsBounds. No-op in the view manager when the workbench view
30
- // was never attached (the host did not opt into the workbench editor).
31
- .handle(ViewChannel.WorkbenchBounds, (_event, ...args) => {
32
- const [bounds] = validate(ViewChannel.WorkbenchBounds, ViewBoundsSchema, args);
33
- ctx.views.setWorkbenchBounds(bounds);
34
- })
35
- // Host-controllable toolbar: forward anchor (the MAIN renderer measures the
36
- // placeholder rect → toolbar WCV bounds). invoke, mirroring
37
- // SimulatorDevtoolsBounds. Sender is the trusted main renderer.
38
- .handle(ViewChannel.HostToolbarBounds, (_event, ...args) => {
39
- const [bounds] = validate(ViewChannel.HostToolbarBounds, ViewBoundsSchema, args);
40
- ctx.views.setHostToolbarBounds(bounds);
23
+ // Window-level placement snapshot: the single source of truth for every
24
+ // managed native view's bounds/visibility/z-order. The renderer's central
25
+ // publisher coalesces one snapshot per frame; the reconciler diffs it
26
+ // against the actual view tree. Supersedes the per-view bounds channels.
27
+ .handle(ViewChannel.PlacementSnapshot, (_event, ...args) => {
28
+ const [snapshot] = validate(ViewChannel.PlacementSnapshot, PlacementSnapshotSchema, args);
29
+ ctx.views.setPlacementSnapshot(snapshot);
41
30
  })
42
31
  // Height replay pull: a freshly-mounted main-renderer placeholder asks for
43
32
  // the last NOTIFIED toolbar height (main retains it — the toolbar's