@dimina-kit/devtools 0.3.2-dev.20260601115343 → 0.3.2-dev.20260610114009

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 (189) hide show
  1. package/README.md +49 -59
  2. package/dist/main/api.d.ts +1 -2
  3. package/dist/main/api.js +5 -1
  4. package/dist/main/app/app.d.ts +15 -5
  5. package/dist/main/app/app.js +249 -122
  6. package/dist/main/app/bootstrap.d.ts +16 -0
  7. package/dist/main/app/bootstrap.js +20 -0
  8. package/dist/main/app/launch.d.ts +8 -4
  9. package/dist/main/app/launch.js +9 -4
  10. package/dist/main/index.bundle.js +6071 -1863
  11. package/dist/main/index.js +9 -1
  12. package/dist/main/ipc/app.d.ts +1 -1
  13. package/dist/main/ipc/bridge-router.d.ts +61 -0
  14. package/dist/main/ipc/bridge-router.js +1333 -0
  15. package/dist/main/ipc/panels.d.ts +1 -1
  16. package/dist/main/ipc/panels.js +6 -2
  17. package/dist/main/ipc/popover.d.ts +1 -1
  18. package/dist/main/ipc/project-fs.d.ts +1 -1
  19. package/dist/main/ipc/projects.d.ts +1 -1
  20. package/dist/main/ipc/session.d.ts +1 -1
  21. package/dist/main/ipc/settings.d.ts +1 -1
  22. package/dist/main/ipc/simulator-module.d.ts +6 -0
  23. package/dist/main/ipc/simulator-module.js +9 -1
  24. package/dist/main/ipc/simulator.d.ts +2 -2
  25. package/dist/main/ipc/simulator.js +29 -5
  26. package/dist/main/ipc/toolbar.d.ts +1 -1
  27. package/dist/main/ipc/views.d.ts +1 -1
  28. package/dist/main/ipc/views.js +40 -2
  29. package/dist/main/runtime/devtools-backend.d.ts +17 -0
  30. package/dist/main/runtime/devtools-backend.js +49 -0
  31. package/dist/main/runtime/miniapp-runtime.d.ts +39 -0
  32. package/dist/main/runtime/miniapp-runtime.js +17 -0
  33. package/dist/main/services/automation/exec.d.ts +27 -2
  34. package/dist/main/services/automation/exec.js +37 -3
  35. package/dist/main/services/automation/handlers/app.js +127 -11
  36. package/dist/main/services/automation/handlers/page.js +36 -19
  37. package/dist/main/services/automation/index.js +40 -13
  38. package/dist/main/services/automation/wait-active-page.d.ts +56 -0
  39. package/dist/main/services/automation/wait-active-page.js +71 -0
  40. package/dist/main/services/console-forward/index.d.ts +36 -0
  41. package/dist/main/services/console-forward/index.js +81 -0
  42. package/dist/main/services/dimina-resource-server.d.ts +7 -0
  43. package/dist/main/services/dimina-resource-server.js +99 -0
  44. package/dist/main/services/elements-forward/index.d.ts +66 -0
  45. package/dist/main/services/elements-forward/index.js +583 -0
  46. package/dist/main/services/layout/index.d.ts +22 -0
  47. package/dist/main/services/layout/index.js +25 -0
  48. package/dist/main/services/mcp/index.d.ts +1 -0
  49. package/dist/main/services/mcp/index.js +3 -0
  50. package/dist/main/services/mcp/server.d.ts +1 -1
  51. package/dist/main/services/mcp/server.js +1 -1
  52. package/dist/main/services/mcp/target-manager.d.ts +35 -0
  53. package/dist/main/services/mcp/target-manager.js +63 -1
  54. package/dist/main/services/mcp/tools/context-tools.js +15 -1
  55. package/dist/main/services/module.d.ts +1 -1
  56. package/dist/main/services/network-forward/index.d.ts +190 -0
  57. package/dist/main/services/network-forward/index.js +765 -0
  58. package/dist/main/services/notifications/renderer-notifier.d.ts +11 -0
  59. package/dist/main/services/notifications/renderer-notifier.js +7 -1
  60. package/dist/main/services/render-inspect/index.d.ts +41 -0
  61. package/dist/main/services/render-inspect/index.js +88 -0
  62. package/dist/main/services/safe-area/index.d.ts +17 -0
  63. package/dist/main/services/safe-area/index.js +59 -0
  64. package/dist/main/services/service-host-pool/pool.d.ts +197 -0
  65. package/dist/main/services/service-host-pool/pool.js +364 -0
  66. package/dist/main/services/simulator/custom-apis.d.ts +34 -0
  67. package/dist/main/services/simulator/custom-apis.js +22 -0
  68. package/dist/main/services/simulator-appdata/index.d.ts +35 -0
  69. package/dist/main/services/simulator-appdata/index.js +71 -0
  70. package/dist/main/services/simulator-current-page/index.d.ts +24 -0
  71. package/dist/main/services/simulator-current-page/index.js +19 -0
  72. package/dist/main/services/simulator-storage/index.d.ts +37 -2
  73. package/dist/main/services/simulator-storage/index.js +209 -32
  74. package/dist/main/services/simulator-storage/service-storage-ops.d.ts +40 -0
  75. package/dist/main/services/simulator-storage/service-storage-ops.js +67 -0
  76. package/dist/main/services/simulator-temp-files/index.d.ts +1 -1
  77. package/dist/main/services/simulator-temp-files/index.js +39 -17
  78. package/dist/main/services/simulator-wxml/index.d.ts +29 -0
  79. package/dist/main/services/simulator-wxml/index.js +28 -0
  80. package/dist/main/services/toolbar/toolbar-store.d.ts +3 -3
  81. package/dist/main/services/toolbar/toolbar-store.js +1 -18
  82. package/dist/main/services/views/devtools-tabs.d.ts +47 -0
  83. package/dist/main/services/views/devtools-tabs.js +95 -0
  84. package/dist/main/services/views/miniapp-partition.d.ts +80 -0
  85. package/dist/main/services/views/miniapp-partition.js +138 -0
  86. package/dist/main/services/views/simulator-session-policy.d.ts +28 -0
  87. package/dist/main/services/views/simulator-session-policy.js +82 -0
  88. package/dist/main/services/views/view-manager.d.ts +125 -0
  89. package/dist/main/services/views/view-manager.js +852 -4
  90. package/dist/main/services/workbench-context.d.ts +67 -7
  91. package/dist/main/services/workbench-context.js +6 -1
  92. package/dist/main/services/workspace/workspace-service.js +7 -13
  93. package/dist/main/utils/ipc-registry.d.ts +1 -1
  94. package/dist/main/utils/ipc-registry.js +21 -4
  95. package/dist/main/utils/paths.d.ts +15 -0
  96. package/dist/main/utils/paths.js +19 -0
  97. package/dist/main/utils/sender-policy.d.ts +9 -3
  98. package/dist/main/utils/sender-policy.js +18 -4
  99. package/dist/main/utils/theme.d.ts +1 -1
  100. package/dist/main/utils/theme.js +1 -1
  101. package/dist/main/windows/main-window/create.d.ts +0 -6
  102. package/dist/main/windows/main-window/create.js +6 -80
  103. package/dist/main/windows/main-window/events.d.ts +1 -1
  104. package/dist/main/windows/main-window/events.js +1 -1
  105. package/dist/main/windows/service-host-window/create.d.ts +67 -0
  106. package/dist/main/windows/service-host-window/create.js +138 -0
  107. package/dist/native-host/common/common.js +368 -0
  108. package/dist/native-host/container/pageFrame.css +1 -0
  109. package/dist/native-host/render/render.js +9334 -0
  110. package/dist/native-host/service/service.js +6 -0
  111. package/dist/preload/index.d.ts +2 -0
  112. package/dist/preload/index.js +6 -0
  113. package/dist/preload/instrumentation/app-data.d.ts +18 -20
  114. package/dist/preload/instrumentation/app-data.js +23 -160
  115. package/dist/preload/instrumentation/wxml-extract.d.ts +1 -1
  116. package/dist/preload/instrumentation/wxml-extract.js +1 -1
  117. package/dist/preload/runtime/bridge.d.ts +3 -8
  118. package/dist/preload/runtime/bridge.js +2 -32
  119. package/dist/preload/runtime/custom-apis.js +20 -17
  120. package/dist/preload/runtime/host-toolbar-advertiser.d.ts +27 -0
  121. package/dist/preload/runtime/host-toolbar-advertiser.js +55 -0
  122. package/dist/preload/runtime/main-api-runner.d.ts +9 -0
  123. package/dist/preload/runtime/main-api-runner.js +85 -0
  124. package/dist/preload/runtime/native-host.d.ts +43 -0
  125. package/dist/preload/runtime/native-host.js +83 -0
  126. package/dist/preload/shared/expose.d.ts +10 -5
  127. package/dist/preload/shared/expose.js +30 -12
  128. package/dist/preload/shared/sid-registry.d.ts +11 -0
  129. package/dist/preload/shared/sid-registry.js +38 -0
  130. package/dist/preload/windows/host-toolbar.cjs +161 -0
  131. package/dist/preload/windows/host-toolbar.cjs.map +7 -0
  132. package/dist/preload/windows/host-toolbar.d.ts +2 -0
  133. package/dist/preload/windows/host-toolbar.js +12 -0
  134. package/dist/preload/windows/main.cjs +1935 -0
  135. package/dist/preload/windows/main.cjs.map +4 -4
  136. package/dist/preload/windows/simulator.cjs +925 -0
  137. package/dist/preload/windows/simulator.cjs.map +7 -0
  138. package/dist/preload/windows/simulator.js +244 -574
  139. package/dist/render-host/pageFrame.html +29 -0
  140. package/dist/render-host/preload.cjs +154 -0
  141. package/dist/render-host/render-inspect.js +295 -0
  142. package/dist/renderer/assets/index-CcJKzatF.js +50 -0
  143. package/dist/renderer/assets/{input-CCYS8Ave.js → input-QGx6FFX1.js} +2 -2
  144. package/dist/renderer/assets/ipc-transport-BT4QYQqu.css +1 -0
  145. package/dist/renderer/assets/{ipc-transport-Bn4KXvhu.js → ipc-transport-DvZvwK-Z.js} +2 -2
  146. package/dist/renderer/assets/{popover-CH4rwpdt.js → popover-BOH1If1J.js} +2 -2
  147. package/dist/renderer/assets/select-C7JQ8G5N.js +2 -0
  148. package/dist/renderer/assets/{settings-CMTB-t0x.js → settings-DpAbTJyF.js} +2 -2
  149. package/dist/renderer/assets/{settings-api-Csdngf9Z.js → settings-api-RD2IZF53.js} +2 -2
  150. package/dist/renderer/assets/{workbenchSettings-CODFCzOg.js → workbenchSettings-CNJX4Qna.js} +2 -2
  151. package/dist/renderer/entries/main/index.html +5 -5
  152. package/dist/renderer/entries/popover/index.html +5 -5
  153. package/dist/renderer/entries/settings/index.html +5 -5
  154. package/dist/renderer/entries/workbench-settings/index.html +4 -4
  155. package/dist/service-host/preload.cjs +254 -0
  156. package/dist/service-host/service.html +10 -0
  157. package/dist/service-host/sourcemap-rewrite.cjs +58 -0
  158. package/dist/service-host/sync-api-patch.js +20 -0
  159. package/dist/service-host/sync-impls/menu-button-geometry.js +8 -0
  160. package/dist/service-host/sync-impls/menu-button.js +16 -0
  161. package/dist/service-host/sync-impls/storage.js +50 -0
  162. package/dist/service-host/sync-impls/system-info.js +54 -0
  163. package/dist/shared/appdata-accumulator.d.ts +95 -0
  164. package/dist/shared/appdata-accumulator.js +213 -0
  165. package/dist/shared/bridge-channels.d.ts +378 -0
  166. package/dist/shared/bridge-channels.js +81 -0
  167. package/dist/shared/ipc-channels.d.ts +84 -1
  168. package/dist/shared/ipc-channels.js +90 -10
  169. package/dist/shared/ipc-schemas.d.ts +59 -2
  170. package/dist/shared/ipc-schemas.js +64 -3
  171. package/dist/shared/open-in-editor.d.ts +71 -0
  172. package/dist/shared/open-in-editor.js +138 -0
  173. package/dist/shared/simulator-api-metadata.d.ts +21 -0
  174. package/dist/shared/simulator-api-metadata.js +23 -0
  175. package/dist/shared/types.d.ts +2 -39
  176. package/dist/simulator/assets/device-shell-DsdcNEkt.css +1 -0
  177. package/dist/simulator/assets/device-shell-fH2cI-3q.js +2 -0
  178. package/dist/simulator/assets/jsx-runtime-P6ZegaOf.js +2 -0
  179. package/dist/simulator/assets/simulator-DHylZf9Z.js +10 -0
  180. package/dist/simulator/assets/simulator-mini-app-BDNu5n26.js +2 -0
  181. package/dist/simulator/device-shell/menu-button-geometry.js +8 -0
  182. package/dist/simulator/simulator.html +2 -112
  183. package/package.json +12 -12
  184. package/dist/main/utils/disposable.d.ts +0 -18
  185. package/dist/main/utils/disposable.js +0 -67
  186. package/dist/renderer/assets/index-BDMxXUfn.js +0 -50
  187. package/dist/renderer/assets/ipc-transport-Cu7tubyg.css +0 -1
  188. package/dist/renderer/assets/select-Ct4-sijm.js +0 -2
  189. package/dist/simulator/assets/simulator-B4e3c6_O.js +0 -9
@@ -0,0 +1,1333 @@
1
+ import { app, ipcMain, protocol, session as electronSession, webContents } from 'electron';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { BRIDGE_CHANNELS as C, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js';
5
+ import { isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js';
6
+ import { devtoolsPackageRoot } from '../utils/paths.js';
7
+ import { createDebugTap } from '@dimina-kit/electron-deck/main';
8
+ import { startDiminaResourceServer } from '../services/dimina-resource-server.js';
9
+ import { buildServiceHostSpawnUrl, createServiceHostWindow, navigateServiceHost, serviceHostSpec, } from '../windows/service-host-window/create.js';
10
+ import { ServiceHostPool } from '../services/service-host-pool/pool.js';
11
+ import { registerMiniappSessionConfigurator, SHARED_MINIAPP_PARTITION, } from '../services/views/miniapp-partition.js';
12
+ import { createConsoleForwarder } from '../services/console-forward/index.js';
13
+ import { STORAGE_API_NAMES } from '../services/simulator-storage/index.js';
14
+ const STACK_ID = 'stack_0';
15
+ /** Hard ceiling on the pre-warm pool, mirroring ServiceHostPool/doc §3.3. */
16
+ const PREWARM_MAX_POOL_SIZE = 4;
17
+ /**
18
+ * Resolve the pre-warm pool size from env (doc §6). Returns 0 (OFF) unless
19
+ * `DIMINA_PREWARM_POOL_SIZE` is a positive integer and `DIMINA_PREWARM_DISABLE`
20
+ * is not set. Default OFF — pooling is opt-in so the spawn path is unchanged
21
+ * unless explicitly enabled.
22
+ */
23
+ function resolvePrewarmPoolSize() {
24
+ if (process.env.DIMINA_PREWARM_DISABLE === '1')
25
+ return 0;
26
+ const raw = process.env.DIMINA_PREWARM_POOL_SIZE;
27
+ if (!raw)
28
+ return 0;
29
+ const n = Number.parseInt(raw, 10);
30
+ if (!Number.isFinite(n) || n <= 0)
31
+ return 0;
32
+ return Math.min(n, PREWARM_MAX_POOL_SIZE);
33
+ }
34
+ /** debugTap (§7) is opt-in via `DIMINA_DEBUG_TAP=1` — off everywhere else. */
35
+ function resolveDebugTapEnabled() {
36
+ return process.env.DIMINA_DEBUG_TAP === '1';
37
+ }
38
+ /**
39
+ * Compact one-line summary of a bridge payload for debugTap — the inner
40
+ * `msg.type` + `bridgeId`, never the full (potentially large) payload blob.
41
+ */
42
+ function summarizeBridgeMsg(payload) {
43
+ if (!payload || typeof payload !== 'object')
44
+ return undefined;
45
+ const p = payload;
46
+ const bridgeId = typeof p.bridgeId === 'string' ? p.bridgeId : undefined;
47
+ const msg = p.msg;
48
+ const type = msg && typeof msg.type === 'string' ? msg.type : undefined;
49
+ const parts = [type, bridgeId ? `bridge=${bridgeId}` : undefined].filter(Boolean);
50
+ return parts.length > 0 ? parts.join(' ') : undefined;
51
+ }
52
+ /** Default timeout for a simulator-forwarded API call. */
53
+ const API_CALL_TIMEOUT_MS = 5_000;
54
+ /**
55
+ * Resolve the "current" app session: prefer an explicit appId, else the
56
+ * workspace's active project, else the only / most-recently-created session.
57
+ * Native-host can host multiple AppSessions, so this picks one deterministically
58
+ * rather than assuming a single session.
59
+ */
60
+ function resolveCurrentApp(state, ctx, appId) {
61
+ if (appId) {
62
+ for (const ap of state.appSessions.values())
63
+ if (ap.appId === appId)
64
+ return ap;
65
+ }
66
+ const appInfo = ctx.workspace?.getSession?.()?.appInfo;
67
+ const activeAppId = appInfo?.appId;
68
+ if (activeAppId) {
69
+ for (const ap of state.appSessions.values())
70
+ if (ap.appId === activeAppId)
71
+ return ap;
72
+ }
73
+ // Maps preserve insertion order; the last entry is the most recent spawn.
74
+ let last;
75
+ for (const ap of state.appSessions.values())
76
+ last = ap;
77
+ return last;
78
+ }
79
+ export function installBridgeRouter(ctx) {
80
+ const state = {
81
+ appSessions: new Map(),
82
+ pageSessions: new Map(),
83
+ wcIdToAppSessionId: new Map(),
84
+ wcIdToBridgeId: new Map(),
85
+ pendingApiCalls: new Map(),
86
+ pool: null,
87
+ emitRenderEvent: () => { },
88
+ connections: ctx.connections,
89
+ debugTap: createDebugTap({ enabled: resolveDebugTapEnabled() }),
90
+ };
91
+ // Opt-in (default OFF) pre-warm pool for service-host windows. When enabled,
92
+ // handleSpawn acquires a warm window instead of constructing one per spawn.
93
+ const prewarmPoolSize = resolvePrewarmPoolSize();
94
+ if (prewarmPoolSize > 0) {
95
+ const pool = new ServiceHostPool();
96
+ state.pool = pool;
97
+ // Defer warm-up off the cold-start critical path (doc §3.3: app.ready +
98
+ // idle). Warming eagerly here races main-window startup — it creates an
99
+ // extra hidden BrowserWindow before the workbench renderer has settled,
100
+ // which made the e2e's first-window/preload-readiness flaky. The timer is
101
+ // cancelled on teardown so it can't warm a disposed pool.
102
+ const warmTimer = setTimeout(() => {
103
+ void pool
104
+ .init({ defaultPoolSize: prewarmPoolSize, defaultSpec: serviceHostSpec(), maxPoolSize: PREWARM_MAX_POOL_SIZE })
105
+ .catch((error) => {
106
+ console.warn('[bridge-router] webview pool warm-up failed:', error);
107
+ });
108
+ }, 500);
109
+ ctx.registry.add(() => clearTimeout(warmTimer));
110
+ ctx.registry.add(() => state.pool?.dispose());
111
+ }
112
+ installResourceProtocolHandlers(ctx, state);
113
+ // The currently-selected device (renderer toolbar). Cached here so it can ride
114
+ // the NATIVE_HOST_ENABLED reply (race-free DeviceShell init) and so the
115
+ // safe-area service can read it when a render-host guest attaches.
116
+ let currentDevice = null;
117
+ // Native-host enablement query. The simulator webview's preload can't read the
118
+ // launch `process.env` (and additionalArguments don't reach webview guests),
119
+ // nor can it compute file paths (no node:path/url in the guest preload), so it
120
+ // asks main synchronously at install time for the flag + the render-host URLs.
121
+ const onNativeHostQuery = (event) => {
122
+ // native-host is the sole runtime: always reply enabled with the render-host
123
+ // file:// URLs the simulator preload needs. `device` rides along so the
124
+ // DeviceShell mounts with the right bezel size synchronously (the renderer
125
+ // pushes SetDeviceInfo before AttachNative, so the cache is populated by the
126
+ // time the simulator preload runs this sendSync).
127
+ const reply = {
128
+ enabled: true,
129
+ renderHostHtmlUrl: pathToFileURL(path.join(devtoolsPackageRoot, 'dist/render-host/pageFrame.html')).toString(),
130
+ renderPreloadUrl: pathToFileURL(path.join(devtoolsPackageRoot, 'dist/render-host/preload.cjs')).toString(),
131
+ device: currentDevice ?? undefined,
132
+ };
133
+ event.returnValue = reply;
134
+ };
135
+ ipcMain.on(C.NATIVE_HOST_ENABLED, onNativeHostQuery);
136
+ ctx.registry.add(() => { ipcMain.removeListener(C.NATIVE_HOST_ENABLED, onNativeHostQuery); });
137
+ // Subscribers to render-side activity (domReady / active-page). Panels that
138
+ // pull from the active render guest (WXML) re-read on these.
139
+ const renderEventListeners = new Set();
140
+ const emitRenderEvent = (event) => {
141
+ for (const listener of renderEventListeners) {
142
+ try {
143
+ listener(event);
144
+ }
145
+ catch (error) {
146
+ console.warn('[bridge-router] render-event listener threw:', error);
147
+ }
148
+ }
149
+ };
150
+ state.emitRenderEvent = emitRenderEvent;
151
+ // Expose a thin accessor over RouterState so other main services (storage,
152
+ // automation, appdata) can resolve live render/service WebContents without
153
+ // owning router state. Getters resolve fresh — the pre-warm pool can swap
154
+ // windows on respawn, so cached handles go stale.
155
+ const bridgeHandle = {
156
+ isNativeHost: () => true,
157
+ resolveRenderWc: (bridgeId) => {
158
+ const page = state.pageSessions.get(bridgeId);
159
+ return page?.renderWc && !page.renderWc.isDestroyed() ? page.renderWc : null;
160
+ },
161
+ getServiceWc: (appId) => {
162
+ const ap = resolveCurrentApp(state, ctx, appId);
163
+ return ap && !ap.serviceWc.isDestroyed() ? ap.serviceWc : null;
164
+ },
165
+ getServiceWcForBridge: (bridgeId) => {
166
+ const page = state.pageSessions.get(bridgeId);
167
+ if (!page)
168
+ return null;
169
+ const ap = state.appSessions.get(page.appSessionId);
170
+ return ap && !ap.serviceWc.isDestroyed() ? ap.serviceWc : null;
171
+ },
172
+ getActiveBridgeId: (appId) => {
173
+ const ap = resolveCurrentApp(state, ctx, appId);
174
+ if (!ap)
175
+ return null;
176
+ // Fall back to the root page (= appSessionId) before the first signal.
177
+ return ap.activeBridgeId ?? ap.appSessionId;
178
+ },
179
+ getPageStack: (appId) => {
180
+ const ap = resolveCurrentApp(state, ctx, appId);
181
+ return ap?.pageStack ?? null;
182
+ },
183
+ getActiveRenderWc: (appId) => {
184
+ const ap = resolveCurrentApp(state, ctx, appId);
185
+ if (!ap)
186
+ return null;
187
+ const page = state.pageSessions.get(ap.activeBridgeId ?? ap.appSessionId);
188
+ return page?.renderWc && !page.renderWc.isDestroyed() ? page.renderWc : null;
189
+ },
190
+ onRenderEvent: (listener) => {
191
+ renderEventListeners.add(listener);
192
+ return () => renderEventListeners.delete(listener);
193
+ },
194
+ getDevice: () => currentDevice,
195
+ setDevice: (device) => {
196
+ currentDevice = device;
197
+ // Push to the live simulator WC(s) so a mounted DeviceShell re-renders the
198
+ // bezel/status-bar/notch. Pre-spawn there is no session yet — the initial
199
+ // device rides NATIVE_HOST_ENABLED instead. Dedupe across app sessions
200
+ // that share one simulator WCV.
201
+ const seen = new Set();
202
+ for (const ap of state.appSessions.values()) {
203
+ const wc = ap.simulatorWc;
204
+ if (wc && !wc.isDestroyed() && !seen.has(wc.id)) {
205
+ seen.add(wc.id);
206
+ wc.send(E.DEVICE_CHANGE, device);
207
+ }
208
+ }
209
+ },
210
+ debugTap: state.debugTap,
211
+ };
212
+ ctx.bridge = bridgeHandle;
213
+ // Always-on guest console fan-out. Owns `ctx.guestConsole` (the sink the
214
+ // consoleLog case below routes to) so that render-layer console output is
215
+ // mirrored into the service host's own console — surfacing it in the embedded
216
+ // Chrome DevTools (attached to the service host) prefixed `[视图]` — regardless
217
+ // of whether an automation client is connected. Automation now SUBSCRIBES to
218
+ // this forwarder instead of clobbering `ctx.guestConsole`.
219
+ const consoleForwarder = createConsoleForwarder(bridgeHandle);
220
+ ctx.consoleForwarder = consoleForwarder;
221
+ ctx.guestConsole = consoleForwarder;
222
+ ctx.registry.add(() => {
223
+ void consoleForwarder.dispose();
224
+ ctx.consoleForwarder = undefined;
225
+ ctx.guestConsole = undefined;
226
+ });
227
+ // DeviceShell → main: record the visible top-of-stack page bridgeId so the
228
+ // accessor above can resolve "the active page". Sender-validated against the
229
+ // app that owns it; ignored for unknown apps/pages.
230
+ const onActivePage = (event, payload) => {
231
+ const ap = state.appSessions.get(payload.appSessionId);
232
+ if (!ap)
233
+ return;
234
+ const senderApp = appByWc(state, event.sender);
235
+ if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
236
+ return;
237
+ if (ap.pages.has(payload.bridgeId)) {
238
+ ap.activeBridgeId = payload.bridgeId;
239
+ const pagePath = state.pageSessions.get(payload.bridgeId)?.pagePath;
240
+ emitRenderEvent({ kind: 'activePage', appId: ap.appId, bridgeId: payload.bridgeId, pagePath });
241
+ }
242
+ };
243
+ ipcMain.on(C.ACTIVE_PAGE, onActivePage);
244
+ ctx.registry.add(() => { ipcMain.removeListener(C.ACTIVE_PAGE, onActivePage); });
245
+ // DeviceShell → main: the full ordered page stack (bottom→top). Stored so
246
+ // automation's App.getPageStack can report a multi-page stack (main has no
247
+ // stack of its own). Sender-validated against the owning app.
248
+ const onPageStack = (event, payload) => {
249
+ const ap = state.appSessions.get(payload.appSessionId);
250
+ if (!ap)
251
+ return;
252
+ const senderApp = appByWc(state, event.sender);
253
+ if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
254
+ return;
255
+ ap.pageStack = payload.stack;
256
+ };
257
+ ipcMain.on(C.PAGE_STACK, onPageStack);
258
+ ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_STACK, onPageStack); });
259
+ ipcMain.handle(C.SPAWN, async (event, opts) => {
260
+ return handleSpawn(state, ctx, event, opts);
261
+ });
262
+ ctx.registry.add(() => { ipcMain.removeHandler(C.SPAWN); });
263
+ ipcMain.handle(C.PAGE_OPEN, async (event, opts) => {
264
+ return handlePageOpen(state, event, opts);
265
+ });
266
+ ctx.registry.add(() => { ipcMain.removeHandler(C.PAGE_OPEN); });
267
+ const onPageClose = (event, payload) => {
268
+ handlePageClose(state, event.sender, payload);
269
+ };
270
+ ipcMain.on(C.PAGE_CLOSE, onPageClose);
271
+ ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_CLOSE, onPageClose); });
272
+ const onPageLifecycle = (event, payload) => {
273
+ handlePageLifecycle(state, event.sender, payload);
274
+ // Native-host AppData: evict the bridge on page teardown so the panel drops
275
+ // its tab (mirrors the default path's postMessage(pageUnload) eviction).
276
+ if (payload.event === 'pageUnload' && ctx.appData) {
277
+ const ap = state.appSessions.get(payload.appSessionId);
278
+ const senderApp = appByWc(state, event.sender);
279
+ if (ap && senderApp && senderApp.appSessionId === ap.appSessionId) {
280
+ ctx.appData.evictBridge(ap.appId, payload.bridgeId);
281
+ }
282
+ }
283
+ };
284
+ ipcMain.on(C.PAGE_LIFECYCLE, onPageLifecycle);
285
+ ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_LIFECYCLE, onPageLifecycle); });
286
+ const onNavCallback = (event, payload) => {
287
+ handleNavCallback(state, event.sender, payload);
288
+ };
289
+ ipcMain.on(C.NAV_CALLBACK, onNavCallback);
290
+ ctx.registry.add(() => { ipcMain.removeListener(C.NAV_CALLBACK, onNavCallback); });
291
+ const onDispose = (event, payload) => {
292
+ const target = resolveAppByBridgeId(state, payload.bridgeId);
293
+ if (!target)
294
+ return;
295
+ // Only the app's service window or simulator window can issue dispose.
296
+ const senderApp = appByWc(state, event.sender);
297
+ if (senderApp && senderApp.appSessionId !== target.appSessionId) {
298
+ console.warn(`[bridge-router] DISPOSE rejected: sender belongs to ${senderApp.appSessionId}, target ${target.appSessionId}`);
299
+ return;
300
+ }
301
+ // Native-host AppData: clear the app's accumulated bridges so a re-opened
302
+ // project doesn't show ghost tabs from the prior session.
303
+ if (ctx.appData) {
304
+ for (const page of target.pages.values())
305
+ ctx.appData.evictBridge(target.appId, page.bridgeId);
306
+ }
307
+ void disposeAppSession(state, target.appSessionId);
308
+ };
309
+ ipcMain.on(C.DISPOSE, onDispose);
310
+ ctx.registry.add(() => { ipcMain.removeListener(C.DISPOSE, onDispose); });
311
+ // debugTap (§7) ingress recorder — near-free no-op unless DIMINA_DEBUG_TAP=1.
312
+ // Hung on the bridge dispatch chokepoint so the cross-wc message flow is
313
+ // inspectable (the first real consumer of the workbench debugTap primitive).
314
+ const tapIn = (channel, sender, payload) => {
315
+ if (!state.debugTap.enabled)
316
+ return;
317
+ state.debugTap.record({
318
+ ts: Date.now(),
319
+ channel,
320
+ direction: 'in',
321
+ connectionId: sender.id,
322
+ appSessionId: appByWc(state, sender)?.appSessionId,
323
+ summary: summarizeBridgeMsg(payload),
324
+ });
325
+ };
326
+ const onServiceInvoke = (event, payload) => {
327
+ tapIn(C.SERVICE_INVOKE, event.sender, payload);
328
+ const ap = appByWc(state, event.sender);
329
+ if (!ap)
330
+ return;
331
+ const page = state.pageSessions.get(payload.bridgeId) ?? state.pageSessions.get(ap.appSessionId);
332
+ if (!page)
333
+ return;
334
+ routeFromService(state, ap, page, payload.msg, ctx);
335
+ };
336
+ ipcMain.on(C.SERVICE_INVOKE, onServiceInvoke);
337
+ ctx.registry.add(() => { ipcMain.removeListener(C.SERVICE_INVOKE, onServiceInvoke); });
338
+ const onServicePublish = (event, payload) => {
339
+ tapIn(C.SERVICE_PUBLISH, event.sender, payload);
340
+ const ap = appByWc(state, event.sender);
341
+ if (!ap)
342
+ return;
343
+ forwardToRender(ap, payload.msg, payload.targetBridgeId);
344
+ // Native-host AppData panel: tap the service→render setData stream centrally
345
+ // (the simulator guest has no Worker to sniff under native-host). Cheap —
346
+ // the tap ignores non-ub/non-page_* messages.
347
+ ctx.appData?.onServiceToRender(ap.appId, payload.msg);
348
+ };
349
+ ipcMain.on(C.SERVICE_PUBLISH, onServicePublish);
350
+ ctx.registry.add(() => { ipcMain.removeListener(C.SERVICE_PUBLISH, onServicePublish); });
351
+ const onRenderInvoke = (event, payload) => {
352
+ tapIn(C.RENDER_INVOKE, event.sender, payload);
353
+ const page = ensureRenderBound(state, event.sender, payload.bridgeId);
354
+ if (!page)
355
+ return;
356
+ const ap = state.appSessions.get(page.appSessionId);
357
+ if (!ap)
358
+ return;
359
+ routeFromRender(state, ap, page, payload.msg, ctx);
360
+ };
361
+ ipcMain.on(C.RENDER_INVOKE, onRenderInvoke);
362
+ ctx.registry.add(() => { ipcMain.removeListener(C.RENDER_INVOKE, onRenderInvoke); });
363
+ const onRenderPublish = (event, payload) => {
364
+ tapIn(C.RENDER_PUBLISH, event.sender, payload);
365
+ const page = ensureRenderBound(state, event.sender, payload.bridgeId);
366
+ if (!page)
367
+ return;
368
+ const ap = state.appSessions.get(page.appSessionId);
369
+ if (!ap)
370
+ return;
371
+ forwardToService(ap, payload.msg);
372
+ };
373
+ ipcMain.on(C.RENDER_PUBLISH, onRenderPublish);
374
+ ctx.registry.add(() => { ipcMain.removeListener(C.RENDER_PUBLISH, onRenderPublish); });
375
+ ipcMain.handle(C.SIMULATOR_API, async (_event, payload) => {
376
+ return ctx.simulatorApis.invoke(payload.name, payload.params);
377
+ });
378
+ ctx.registry.add(() => { ipcMain.removeHandler(C.SIMULATOR_API); });
379
+ const onApiResponse = (event, payload) => {
380
+ tapIn(C.API_RESPONSE, event.sender, payload);
381
+ handleApiResponse(state, event.sender, payload);
382
+ };
383
+ ipcMain.on(C.API_RESPONSE, onApiResponse);
384
+ ctx.registry.add(() => { ipcMain.removeListener(C.API_RESPONSE, onApiResponse); });
385
+ ctx.registry.add(async () => {
386
+ // Clear any in-flight pending API timers before tearing down sessions so a
387
+ // late simulator response cannot fire a callback against a torn-down app.
388
+ for (const pending of state.pendingApiCalls.values())
389
+ clearTimeout(pending.timer);
390
+ state.pendingApiCalls.clear();
391
+ });
392
+ ctx.registry.add(async () => {
393
+ await Promise.all(Array.from(state.appSessions.keys()).map(id => disposeAppSession(state, id)));
394
+ });
395
+ }
396
+ // ── Spawn / Open / Close ─────────────────────────────────────────────────────
397
+ async function handleSpawn(state, ctx, event, opts) {
398
+ const appId = opts.appId;
399
+ if (!appId)
400
+ throw new Error('[bridge-router] spawn requires appId');
401
+ const bridgeId = opts.bridgeId || newBridgeId();
402
+ const appSessionId = bridgeId;
403
+ const simulatorWc = resolveSimulatorWebContents(ctx, opts.simulatorWcId, event.sender);
404
+ const pagePath = normalizePagePath(opts.pagePath || 'pages/index/index');
405
+ const pkgRoot = path.resolve(opts.pkgRoot || process.cwd());
406
+ const root = opts.root || 'main';
407
+ // Resource base resolution. Preferred: the simulator-supplied dev-server
408
+ // origin, which statically serves the compiled `<appId>/<root>/…` tree (same
409
+ // source the default dimina-fe `<webview>` reads). Fallback (no dev server —
410
+ // legacy/unit tests): a local server rooted at `pkgRoot/root`. Either way
411
+ // `resourceBaseUrl` is the single base all fetches resolve against.
412
+ let resourceServer = null;
413
+ let resourceBaseUrl;
414
+ if (opts.resourceBaseUrl) {
415
+ resourceBaseUrl = opts.resourceBaseUrl.endsWith('/') ? opts.resourceBaseUrl : `${opts.resourceBaseUrl}/`;
416
+ }
417
+ else {
418
+ resourceServer = await startDiminaResourceServer(path.resolve(pkgRoot, root));
419
+ resourceBaseUrl = resourceServer.baseUrl;
420
+ }
421
+ // The selected device (renderer toolbar) is the authoritative source for the
422
+ // logical dims a spawn must report. The simulator-supplied `hostEnvSnapshot`
423
+ // is derived from the device baked into the simulator at BOOT time, so on a
424
+ // RESPAWN after a live device change it still carries the boot device. Layer
425
+ // the live `currentDevice` on top so every spawn/respawn reports the selected
426
+ // device — matching what the live `SetDeviceInfo` HostEnvUpdate pushes to an
427
+ // already-running service host. Pre-selection (null) → simulator snapshot wins.
428
+ const selectedDevice = ctx.bridge?.getDevice?.() ?? null;
429
+ const hostEnv = makeHostEnv({
430
+ ...opts.hostEnvSnapshot,
431
+ ...(selectedDevice ? deviceInfoToHostEnv(selectedDevice) : {}),
432
+ });
433
+ // app-config.json lives at `<base><appId>/<root>/app-config.json` on the dev
434
+ // server, or at the local server root for the fallback path.
435
+ const appConfig = await loadAppConfig(resourceServer ? resourceServer.baseUrl : `${resourceBaseUrl}${appId}/${root}/`);
436
+ const manifest = buildAppManifest(appConfig, pagePath);
437
+ const rootWindowConfig = resolvePageWindowConfig(appConfig, pagePath);
438
+ const isTab = isTabPage(appConfig, pagePath);
439
+ // Acquire a pre-warmed service-host window when pooling is enabled; otherwise
440
+ // construct one fresh (default). A pooled/fallback window is warmed on
441
+ // about:blank and must be navigated to the spawn URL below; the fresh path
442
+ // navigates inside createServiceHostWindow.
443
+ const usedPool = state.pool !== null;
444
+ let poolEntryId = null;
445
+ let serviceWindow;
446
+ if (state.pool) {
447
+ const acquired = await state.pool.acquire(serviceHostSpec());
448
+ serviceWindow = acquired.win;
449
+ poolEntryId = acquired.entryId;
450
+ }
451
+ else {
452
+ serviceWindow = createServiceHostWindow({
453
+ bridgeId,
454
+ appId,
455
+ pagePath,
456
+ pkgRoot,
457
+ resourceBaseUrl,
458
+ hostEnvSnapshot: hostEnv,
459
+ });
460
+ }
461
+ const appSession = {
462
+ appSessionId,
463
+ appId,
464
+ pkgRoot,
465
+ root,
466
+ scene: opts.scene ?? 1001,
467
+ serviceWindow,
468
+ serviceWc: serviceWindow.webContents,
469
+ simulatorWc,
470
+ serviceLoaded: false,
471
+ resourceBaseUrl,
472
+ resourceServer,
473
+ hostEnv,
474
+ appConfig,
475
+ manifest,
476
+ pages: new Map(),
477
+ activeBridgeId: null,
478
+ poolEntryId,
479
+ onServiceClosed: null,
480
+ onServiceBoot: null,
481
+ };
482
+ const rootPage = {
483
+ bridgeId,
484
+ appSessionId,
485
+ pagePath,
486
+ query: opts.query ?? {},
487
+ isRoot: true,
488
+ isTab,
489
+ renderWc: null,
490
+ renderLoaded: false,
491
+ resourceLoadedSent: false,
492
+ windowConfig: rootWindowConfig,
493
+ };
494
+ state.appSessions.set(appSessionId, appSession);
495
+ state.pageSessions.set(bridgeId, rootPage);
496
+ appSession.pages.set(bridgeId, rootPage);
497
+ bindWc(state.wcIdToAppSessionId, serviceWindow.webContents, appSessionId);
498
+ bindWc(state.wcIdToAppSessionId, simulatorWc, appSessionId);
499
+ // Track the service-host webContents as a Connection (foundation.md §4.3) and
500
+ // own this session's serviceWc→appSessionId binding on its CURRENT lifetime
501
+ // segment. On a pooled-window REUSE, `disposeAppSession` calls
502
+ // `connections.reset(serviceWc.id)` → this owned cleanup fires + a fresh
503
+ // segment opens, so the next session rebinding the same wc starts clean; on a
504
+ // real destroy the connection closes and fires it too. The cleanup is guarded
505
+ // by `appSessionId` so it only clears a binding that is still THIS session's.
506
+ // (The simulatorWc binding stays manual in `disposeAppSession`: the simulator
507
+ // wc outlives individual app sessions — cross-connection state, §4.4 — so it
508
+ // cannot hang off any single session's segment.)
509
+ state.connections.acquire(serviceWindow.webContents).own(() => {
510
+ if (state.wcIdToAppSessionId.get(appSession.serviceWc.id) === appSessionId) {
511
+ state.wcIdToAppSessionId.delete(appSession.serviceWc.id);
512
+ }
513
+ });
514
+ ctx.registry.add(() => disposeAppSession(state, appSessionId));
515
+ const onServiceClosed = () => {
516
+ void disposeAppSession(state, appSessionId, { serviceAlreadyClosed: true });
517
+ };
518
+ appSession.onServiceClosed = onServiceClosed;
519
+ serviceWindow.once('closed', onServiceClosed);
520
+ if (usedPool) {
521
+ // A pooled/fallback window passes through about:blank (warm load or the
522
+ // fallback's initial load). Boot only once the REAL service.html navigation
523
+ // finishes — filtering by URL so an about:blank did-finish-load can't fire a
524
+ // premature boot. The listener removes itself after the first match.
525
+ const bootOnServiceLoad = () => {
526
+ if (serviceWindow.isDestroyed())
527
+ return;
528
+ // This session was disposed before its service.html settled: self-evict so
529
+ // a recycled window doesn't carry a stale listener into the next spawn.
530
+ if (state.appSessions.get(appSessionId) !== appSession) {
531
+ serviceWindow.webContents.removeListener('did-finish-load', bootOnServiceLoad);
532
+ return;
533
+ }
534
+ // Ignore the warm/about:blank load; only the real service.html boots.
535
+ if (!serviceWindow.webContents.getURL().includes('service.html'))
536
+ return;
537
+ serviceWindow.webContents.removeListener('did-finish-load', bootOnServiceLoad);
538
+ void bootServiceHost(state, appSession);
539
+ };
540
+ appSession.onServiceBoot = bootOnServiceLoad;
541
+ serviceWindow.webContents.on('did-finish-load', bootOnServiceLoad);
542
+ void navigateServiceHost(serviceWindow, buildServiceHostSpawnUrl({
543
+ bridgeId,
544
+ appId,
545
+ pagePath,
546
+ pkgRoot,
547
+ resourceBaseUrl,
548
+ hostEnvSnapshot: hostEnv,
549
+ }));
550
+ }
551
+ else {
552
+ // Fresh window: its only navigation is service.html (issued inside
553
+ // createServiceHostWindow), so the first did-finish-load is the spawn load.
554
+ serviceWindow.webContents.once('did-finish-load', () => {
555
+ void bootServiceHost(state, appSession);
556
+ });
557
+ }
558
+ return {
559
+ appSessionId,
560
+ bridgeId,
561
+ pagePath,
562
+ serviceWcId: serviceWindow.webContents.id,
563
+ resourceBaseUrl,
564
+ manifest,
565
+ rootWindowConfig,
566
+ };
567
+ }
568
+ async function handlePageOpen(state, event, opts) {
569
+ const ap = state.appSessions.get(opts.appSessionId);
570
+ if (!ap)
571
+ throw new Error(`[bridge-router] PAGE_OPEN unknown appSession ${opts.appSessionId}`);
572
+ // Only the simulator window owning the app can open additional pages.
573
+ const senderApp = appByWc(state, event.sender);
574
+ if (!senderApp || senderApp.appSessionId !== opts.appSessionId) {
575
+ throw new Error('[bridge-router] PAGE_OPEN rejected: caller not bound to app session');
576
+ }
577
+ const pagePath = normalizePagePath(opts.pagePath);
578
+ const bridgeId = opts.bridgeId || newBridgeId();
579
+ const windowConfig = resolvePageWindowConfig(ap.appConfig, pagePath);
580
+ const isTab = isTabPage(ap.appConfig, pagePath);
581
+ const page = {
582
+ bridgeId,
583
+ appSessionId: opts.appSessionId,
584
+ pagePath,
585
+ query: opts.query ?? {},
586
+ isRoot: false,
587
+ isTab,
588
+ renderWc: null,
589
+ renderLoaded: false,
590
+ resourceLoadedSent: false,
591
+ windowConfig,
592
+ };
593
+ state.pageSessions.set(bridgeId, page);
594
+ ap.pages.set(bridgeId, page);
595
+ return { bridgeId, pagePath, windowConfig, isTab };
596
+ }
597
+ function handlePageClose(state, sender, payload) {
598
+ const page = state.pageSessions.get(payload.bridgeId);
599
+ if (!page)
600
+ return;
601
+ if (page.isRoot) {
602
+ console.warn('[bridge-router] PAGE_CLOSE refused on root page; use DISPOSE');
603
+ return;
604
+ }
605
+ const ap = state.appSessions.get(page.appSessionId);
606
+ if (!ap)
607
+ return;
608
+ const senderApp = appByWc(state, sender);
609
+ if (!senderApp || senderApp.appSessionId !== ap.appSessionId) {
610
+ console.warn('[bridge-router] PAGE_CLOSE rejected: caller not bound to app session');
611
+ return;
612
+ }
613
+ disposePageSession(state, ap, page);
614
+ }
615
+ function handlePageLifecycle(state, sender, payload) {
616
+ const ap = state.appSessions.get(payload.appSessionId);
617
+ if (!ap)
618
+ return;
619
+ const senderApp = appByWc(state, sender);
620
+ if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
621
+ return;
622
+ forwardToService(ap, {
623
+ type: payload.event,
624
+ target: 'service',
625
+ body: { bridgeId: payload.bridgeId },
626
+ });
627
+ }
628
+ function handleNavCallback(state, sender, payload) {
629
+ const ap = state.appSessions.get(payload.appSessionId);
630
+ if (!ap)
631
+ return;
632
+ const senderApp = appByWc(state, sender);
633
+ if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
634
+ return;
635
+ const result = { errMsg: payload.errMsg };
636
+ if (payload.ok) {
637
+ sendCallback(ap, payload.callbacks.success, result);
638
+ }
639
+ else {
640
+ sendCallback(ap, payload.callbacks.fail, result);
641
+ }
642
+ sendCallback(ap, payload.callbacks.complete, result);
643
+ }
644
+ // ── Service-host boot & per-page resource handshake ──────────────────────────
645
+ async function bootServiceHost(state, ap) {
646
+ // Liveness guard: never boot a session that was already disposed. With pooling,
647
+ // the service window is recycled, so a stale did-finish-load listener from an
648
+ // early-disposed prior owner could otherwise fire here and inject the wrong
649
+ // app's logic.js into the next spawn (the recycled webContents is shared).
650
+ if (state.appSessions.get(ap.appSessionId) !== ap)
651
+ return;
652
+ await injectLogicBundle(ap);
653
+ // serviceLoaded is flipped only when service responds with
654
+ // `serviceResourceLoaded`; see handleContainerMsg. Setting it here would
655
+ // race a per-page `resourceLoaded` ahead of the service-side handler.
656
+ forwardToService(ap, makeLoadResource(ap, ap.pages.get(ap.appSessionId), 'service'));
657
+ }
658
+ async function injectLogicBundle(ap) {
659
+ // Fetch the compiled service logic over HTTP from the same base the render
660
+ // host reads (`<base><appId>/<root>/logic.js`). The fallback local server
661
+ // serves its root, so `<base>logic.js` resolves there too — both are http.
662
+ const logicUrl = ap.resourceServer
663
+ ? new URL('logic.js', ap.resourceBaseUrl).toString()
664
+ : new URL(`${ap.appId}/${ap.root}/logic.js`, ap.resourceBaseUrl).toString();
665
+ try {
666
+ const res = await fetch(logicUrl);
667
+ if (!res.ok)
668
+ throw new Error(`logic.js fetch ${res.status} at ${logicUrl}`);
669
+ const logicContent = await res.text();
670
+ await ap.serviceWc.executeJavaScript(`${logicContent}\n//# sourceURL=${logicUrl}`, true);
671
+ }
672
+ catch (error) {
673
+ console.warn('[bridge-router] unable to inject service logic.js:', error);
674
+ }
675
+ }
676
+ function maybeSendResourceLoaded(ap, page) {
677
+ if (page.resourceLoadedSent || !ap.serviceLoaded || !page.renderLoaded)
678
+ return;
679
+ page.resourceLoadedSent = true;
680
+ // Non-root pages (navigateTo / redirectTo / reLaunch / non-cached switchTab)
681
+ // were never sent a SERVICE-side loadResource — only the root gets one at
682
+ // bootServiceHost. Without it the service never `modRequire`s the page module,
683
+ // so `getModuleByPath` misses and `createInstance` no-ops: the page mounts on
684
+ // the render side but its service instance — and thus its AppData / WXML /
685
+ // `Page.getData` — never exist. Mirror the render side, which sends
686
+ // loadResource per page on `renderHostReady`. `loadResource` only registers
687
+ // the module definition (no firstRender), and same-channel FIFO ordering
688
+ // guarantees the module is registered before the `resourceLoaded` below runs
689
+ // `createInstance`. Safety invariants 1-4 reviewed in
690
+ // docs/native-host-abstractions.md.
691
+ if (!page.isRoot) {
692
+ forwardToService(ap, makeLoadResource(ap, page, 'service'));
693
+ }
694
+ forwardToService(ap, {
695
+ type: 'resourceLoaded',
696
+ target: 'service',
697
+ body: {
698
+ bridgeId: page.bridgeId,
699
+ scene: ap.scene,
700
+ pagePath: page.pagePath,
701
+ query: page.query,
702
+ stackId: STACK_ID,
703
+ },
704
+ });
705
+ }
706
+ // ── Message routing ──────────────────────────────────────────────────────────
707
+ function routeFromService(state, ap, defaultPage, msg, ctx) {
708
+ if (msg.type === 'serviceResourceLoaded') {
709
+ handleContainerMsg(ap, defaultPage, msg, ctx, state);
710
+ return;
711
+ }
712
+ if (msg.target === 'render') {
713
+ forwardToRender(ap, msg);
714
+ return;
715
+ }
716
+ if (msg.target === 'container') {
717
+ const page = pageFromMsg(state, ap, msg) ?? defaultPage;
718
+ handleContainerMsg(ap, page, msg, ctx, state);
719
+ }
720
+ }
721
+ function routeFromRender(state, ap, page, msg, ctx) {
722
+ if (msg.type === 'renderHostReady') {
723
+ if (page.renderWc && !page.renderWc.isDestroyed()) {
724
+ page.renderWc.send(C.TO_RENDER, { msg: makeLoadResource(ap, page, 'render') });
725
+ }
726
+ return;
727
+ }
728
+ if (msg.type === 'renderResourceLoaded') {
729
+ handleContainerMsg(ap, page, msg, ctx, state);
730
+ return;
731
+ }
732
+ if (msg.target === 'service') {
733
+ forwardToService(ap, msg);
734
+ return;
735
+ }
736
+ if (msg.target === 'container') {
737
+ handleContainerMsg(ap, page, msg, ctx, state);
738
+ }
739
+ }
740
+ function handleContainerMsg(ap, page, msg, ctx, state) {
741
+ switch (msg.type) {
742
+ case 'serviceResourceLoaded':
743
+ ap.serviceLoaded = true;
744
+ for (const p of ap.pages.values())
745
+ maybeSendResourceLoaded(ap, p);
746
+ break;
747
+ case 'renderResourceLoaded': {
748
+ const target = readBridgeId(msg);
749
+ const p = (target && ap.pages.get(target)) || page;
750
+ p.renderLoaded = true;
751
+ maybeSendResourceLoaded(ap, p);
752
+ break;
753
+ }
754
+ case 'domReady':
755
+ if (!ap.simulatorWc.isDestroyed()) {
756
+ ap.simulatorWc.send(E.DOM_READY, { bridgeId: page.bridgeId });
757
+ }
758
+ state.emitRenderEvent({ kind: 'domReady', appId: ap.appId, bridgeId: page.bridgeId });
759
+ break;
760
+ case 'invokeAPI':
761
+ void handleSimulatorApi(state, ap, page, msg.body, ctx);
762
+ break;
763
+ case 'serviceHostError':
764
+ console.warn('[bridge-router] service host error:', msg.body);
765
+ break;
766
+ case 'consoleLog':
767
+ // Native-host console capture: the render-host / service-host guest preloads
768
+ // monkeypatch console.* and post each entry here (one case handles both —
769
+ // distinguish via msg.body.source). Forward to the console sink (set by the
770
+ // automation server under native-host) so it can rebroadcast as App.logAdded.
771
+ // Guarded + non-throwing: console capture must never break message routing.
772
+ ctx.guestConsole?.emit(msg.body);
773
+ break;
774
+ default:
775
+ break;
776
+ }
777
+ }
778
+ // ── Simulator-window-resident APIs ──────────────────────────────────────────
779
+ const NAV_BAR_API_NAMES = new Set([
780
+ 'setNavigationBarTitle',
781
+ 'setNavigationBarColor',
782
+ 'showNavigationBarLoading',
783
+ 'hideNavigationBarLoading',
784
+ 'hideHomeButton',
785
+ ]);
786
+ const NAV_ACTION_NAMES = new Set([
787
+ 'navigateTo',
788
+ 'navigateBack',
789
+ 'redirectTo',
790
+ 'reLaunch',
791
+ 'switchTab',
792
+ ]);
793
+ const TAB_ACTION_NAMES = new Set([
794
+ 'setTabBarStyle',
795
+ 'setTabBarItem',
796
+ 'showTabBar',
797
+ 'hideTabBar',
798
+ 'setTabBarBadge',
799
+ 'removeTabBarBadge',
800
+ 'showTabBarRedDot',
801
+ 'hideTabBarRedDot',
802
+ ]);
803
+ async function handleSimulatorApi(state, ap, page, body, ctx) {
804
+ const name = String(body.name ?? '');
805
+ const params = normalizeParams(body.params);
806
+ if (NAV_BAR_API_NAMES.has(name)) {
807
+ if (!ap.simulatorWc.isDestroyed()) {
808
+ ap.simulatorWc.send(E.NAV_BAR, {
809
+ bridgeId: page.bridgeId,
810
+ name,
811
+ params,
812
+ });
813
+ }
814
+ const successResult = { errMsg: `${name}:ok` };
815
+ sendCallback(ap, params.success, successResult);
816
+ sendCallback(ap, params.complete, successResult);
817
+ return;
818
+ }
819
+ if (NAV_ACTION_NAMES.has(name)) {
820
+ const payload = {
821
+ appSessionId: ap.appSessionId,
822
+ bridgeId: page.bridgeId,
823
+ name: name,
824
+ params,
825
+ callbacks: extractCallbacks(params),
826
+ };
827
+ if (!ap.simulatorWc.isDestroyed()) {
828
+ ap.simulatorWc.send(E.NAV_ACTION, payload);
829
+ }
830
+ else {
831
+ const fail = { errMsg: `${name}:fail simulator window destroyed` };
832
+ sendCallback(ap, params.fail, fail);
833
+ sendCallback(ap, params.complete, fail);
834
+ }
835
+ return;
836
+ }
837
+ if (TAB_ACTION_NAMES.has(name)) {
838
+ const payload = {
839
+ appSessionId: ap.appSessionId,
840
+ bridgeId: page.bridgeId,
841
+ name: name,
842
+ params,
843
+ callbacks: extractCallbacks(params),
844
+ };
845
+ if (!ap.simulatorWc.isDestroyed()) {
846
+ ap.simulatorWc.send(E.TAB_ACTION, payload);
847
+ }
848
+ else {
849
+ const fail = { errMsg: `${name}:fail simulator window destroyed` };
850
+ sendCallback(ap, params.fail, fail);
851
+ sendCallback(ap, params.complete, fail);
852
+ }
853
+ return;
854
+ }
855
+ // Native-host storage unification: route async wx.setStorage/getStorage/etc.
856
+ // to the service-host window's file:// store (the same store the *Sync APIs +
857
+ // the Storage panel use), instead of forwarding to the simulator guest's
858
+ // http:// origin. Without this, async writes and sync writes diverge across
859
+ // two origins even for the running mini-app.
860
+ if (ctx.storageApi && STORAGE_API_NAMES.has(name)) {
861
+ try {
862
+ const result = await ctx.storageApi.invoke(ap.appId, name, params);
863
+ sendCallback(ap, params.success, result);
864
+ sendCallback(ap, params.complete, result);
865
+ }
866
+ catch (error) {
867
+ const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
868
+ sendCallback(ap, params.fail, failResult);
869
+ sendCallback(ap, params.complete, failResult);
870
+ }
871
+ return;
872
+ }
873
+ // Main-process registry (downstream-registered host APIs via
874
+ // instance.registerSimulatorApi). If the name is not registered here, fall
875
+ // through to the simulator-window forwarding branch — the simulator-resident
876
+ // MiniApp owns the DOM-touching defaults (wx.getSystemInfo, chooseImage,
877
+ // chooseMedia, fs.*, …) and the bridge-router can't run those itself.
878
+ if (ctx.simulatorApis.has(name)) {
879
+ try {
880
+ const result = await ctx.simulatorApis.invoke(name, params);
881
+ sendCallback(ap, params.success, result);
882
+ sendCallback(ap, params.complete, result);
883
+ }
884
+ catch (error) {
885
+ const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
886
+ sendCallback(ap, params.fail, failResult);
887
+ sendCallback(ap, params.complete, failResult);
888
+ }
889
+ return;
890
+ }
891
+ forwardApiCallToSimulator(state, ap, page, name, params);
892
+ }
893
+ function forwardApiCallToSimulator(state, ap, page, name, params) {
894
+ const callbacks = extractCallbacks(params);
895
+ if (ap.simulatorWc.isDestroyed()) {
896
+ const fail = { errMsg: `${name}:fail simulator window destroyed` };
897
+ sendCallback(ap, callbacks.fail, fail);
898
+ sendCallback(ap, callbacks.complete, fail);
899
+ return;
900
+ }
901
+ const requestId = newRequestId();
902
+ // Persistent subscriptions (`keep: true`, e.g. audioListen) bind their event
903
+ // bridge synchronously but only emit their FIRST response when the first
904
+ // event fires (which can be well past the 5s no-handler window). Arming the
905
+ // one-shot timeout would tear the subscription down before it ever delivers,
906
+ // so keep calls run without a timeout and are reaped on page/app teardown.
907
+ // Recognise persistent subscriptions BY NAME (`audioListen`) too: the service
908
+ // host strips the original `keep: true` before forwarding, so `params.keep` is
909
+ // gone by the time we route the call. See shared/simulator-api-metadata.ts.
910
+ const keep = params.keep === true || isPersistentSimulatorApi(name);
911
+ const timer = keep
912
+ ? undefined
913
+ : setTimeout(() => {
914
+ const pending = state.pendingApiCalls.get(requestId);
915
+ if (!pending)
916
+ return;
917
+ state.pendingApiCalls.delete(requestId);
918
+ const target = state.appSessions.get(pending.appSessionId);
919
+ if (!target)
920
+ return;
921
+ const fail = { errMsg: `${pending.name}:fail no handler (timeout)` };
922
+ sendCallback(target, pending.callbacks.fail, fail);
923
+ sendCallback(target, pending.callbacks.complete, fail);
924
+ }, API_CALL_TIMEOUT_MS);
925
+ state.pendingApiCalls.set(requestId, {
926
+ appSessionId: ap.appSessionId,
927
+ callbacks,
928
+ name,
929
+ timer,
930
+ });
931
+ // Strip the callback ids before forwarding: callback ids are
932
+ // service-host-side identifiers and the simulator runs its own
933
+ // capture-based callback shim. Forwarding them would be both noisy
934
+ // and a potential id-namespace collision.
935
+ const forwardedParams = {};
936
+ for (const [k, v] of Object.entries(params)) {
937
+ if (k === 'success' || k === 'fail' || k === 'complete')
938
+ continue;
939
+ forwardedParams[k] = v;
940
+ }
941
+ const payload = {
942
+ appSessionId: ap.appSessionId,
943
+ bridgeId: page.bridgeId,
944
+ requestId,
945
+ name,
946
+ params: forwardedParams,
947
+ // Echoed back unchanged in the API_RESPONSE so main can ack against the
948
+ // original service-side callback ids without keeping them in pending.
949
+ callbacks,
950
+ };
951
+ ap.simulatorWc.send(E.API_CALL, payload);
952
+ }
953
+ function handleApiResponse(state, sender, payload) {
954
+ const pending = state.pendingApiCalls.get(payload.requestId);
955
+ if (!pending)
956
+ return;
957
+ const ap = state.appSessions.get(pending.appSessionId);
958
+ if (!ap) {
959
+ // Owning app is gone — drop the stale entry regardless of keep.
960
+ state.pendingApiCalls.delete(payload.requestId);
961
+ clearTimeout(pending.timer);
962
+ return;
963
+ }
964
+ // Only the simulator window bound to that app may respond. Validate BEFORE
965
+ // mutating pending state so a spoofed/foreign response can't tear down a
966
+ // live subscription.
967
+ const senderApp = appByWc(state, sender);
968
+ if (!senderApp || senderApp.appSessionId !== ap.appSessionId) {
969
+ console.warn('[bridge-router] API_RESPONSE rejected: sender not bound to app session');
970
+ return;
971
+ }
972
+ // Persistent-subscription fire (keep: true, e.g. audioListen). Re-fire the
973
+ // service-side success callback on EVERY response without tearing the call
974
+ // down: do not delete pending, do not fire `complete`. The 5s no-handler
975
+ // timeout is no longer relevant once the subscription has produced a fire,
976
+ // so clear it but keep the entry alive until page/app teardown (which
977
+ // drains pendingApiCalls in the dispose path).
978
+ // `payload.keep` OR a by-name persistent API: be robust even if the verdict
979
+ // on the wire didn't echo `keep` (the service host strips it), so the router
980
+ // never tears a known subscription (`audioListen`) down as a one-shot.
981
+ if ((payload.keep || isPersistentSimulatorApi(pending.name)) && payload.ok) {
982
+ clearTimeout(pending.timer);
983
+ pending.timer = undefined;
984
+ sendCallback(ap, pending.callbacks.success, payload.result);
985
+ return;
986
+ }
987
+ // One-shot path (default): fire the verdict's callbacks once and clean up.
988
+ state.pendingApiCalls.delete(payload.requestId);
989
+ clearTimeout(pending.timer);
990
+ if (payload.ok) {
991
+ sendCallback(ap, pending.callbacks.success, payload.result);
992
+ sendCallback(ap, pending.callbacks.complete, payload.result ?? { errMsg: `${pending.name}:ok` });
993
+ }
994
+ else {
995
+ const fail = { errMsg: payload.errMsg ?? `${pending.name}:fail` };
996
+ sendCallback(ap, pending.callbacks.fail, fail);
997
+ sendCallback(ap, pending.callbacks.complete, fail);
998
+ }
999
+ }
1000
+ function newRequestId() {
1001
+ return `apicall_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1002
+ }
1003
+ function extractCallbacks(params) {
1004
+ return { success: params.success, fail: params.fail, complete: params.complete };
1005
+ }
1006
+ function normalizeParams(params) {
1007
+ return params && typeof params === 'object' && !Array.isArray(params)
1008
+ ? params
1009
+ : { value: params };
1010
+ }
1011
+ function sendCallback(ap, id, args) {
1012
+ if (id === undefined || id === null)
1013
+ return;
1014
+ forwardToService(ap, {
1015
+ type: 'triggerCallback',
1016
+ target: 'service',
1017
+ body: { id, args },
1018
+ });
1019
+ }
1020
+ // ── Resource helpers ────────────────────────────────────────────────────────
1021
+ function makeLoadResource(ap, page, target) {
1022
+ return {
1023
+ type: 'loadResource',
1024
+ target,
1025
+ body: {
1026
+ appId: ap.appId,
1027
+ bridgeId: page.bridgeId,
1028
+ pagePath: page.pagePath,
1029
+ root: ap.root,
1030
+ baseUrl: ap.resourceBaseUrl,
1031
+ resourceBaseUrl: ap.resourceBaseUrl,
1032
+ // dimina's service runtime reads hostEnv as `{ systemInfo, menuRect }`
1033
+ // (core/host-env.js init → getSystemInfo/getMenuRect; invokeAPI resolves
1034
+ // getSystemInfoSync/getWindowInfo/getDeviceInfo from hostEnv.systemInfo).
1035
+ // We previously sent the FLAT HostEnvSnapshot, so `systemInfo` was null →
1036
+ // `wx.getSystemInfoSync()` returned null and pages reading
1037
+ // `.screenWidth` threw. Nest it under `systemInfo`. (render does NOT read
1038
+ // hostEnv, so this is service-only; the devtools sync-api-patch reads the
1039
+ // separate __diminaSpawnContext.hostEnvSnapshot and is unaffected.)
1040
+ // menuRect stays null as before — getMenuButtonBoundingClientRect is
1041
+ // served by the sync-api-patch / DeviceShell capsule, not this path.
1042
+ hostEnv: { systemInfo: ap.hostEnv, menuRect: null },
1043
+ },
1044
+ };
1045
+ }
1046
+ function forwardToService(ap, msg) {
1047
+ if (!ap.serviceWc.isDestroyed()) {
1048
+ ap.serviceWc.send(C.TO_SERVICE, { msg });
1049
+ }
1050
+ }
1051
+ function forwardToRender(ap, msg, targetBridgeId) {
1052
+ const renderBridgeId = targetBridgeId || readBridgeId(msg);
1053
+ if (!renderBridgeId) {
1054
+ throw new Error('[bridge-router] cannot route to render: missing bridgeId in body and no explicit target');
1055
+ }
1056
+ const page = ap.pages.get(renderBridgeId);
1057
+ if (!page)
1058
+ return;
1059
+ const renderWc = page.renderWc;
1060
+ if (renderWc && !renderWc.isDestroyed()) {
1061
+ renderWc.send(C.TO_RENDER, { msg });
1062
+ }
1063
+ }
1064
+ function ensureRenderBound(state, sender, bridgeId) {
1065
+ const page = state.pageSessions.get(bridgeId);
1066
+ if (!page)
1067
+ return undefined;
1068
+ if (page.renderWc !== sender) {
1069
+ if (page.renderWc && page.renderWc !== sender && !page.renderWc.isDestroyed()) {
1070
+ console.warn(`[bridge-router] page ${bridgeId} render webview swap (wc ${page.renderWc.id} → ${sender.id})`);
1071
+ }
1072
+ page.renderWc = sender;
1073
+ state.wcIdToBridgeId.set(sender.id, bridgeId);
1074
+ // Consolidate the render-guest bookkeeping teardown onto the connection
1075
+ // layer (foundation.md §4 / P2): the render guest is its own webContents =
1076
+ // its own connection (§4.4); own() ties this cleanup to its lifetime so it
1077
+ // fires on destroy, replacing the bespoke `once('destroyed')`. acquire() is
1078
+ // idempotent — re-binding the same sender re-uses its connection.
1079
+ state.connections.acquire(sender).own(() => {
1080
+ const p = state.pageSessions.get(bridgeId);
1081
+ if (p && p.renderWc === sender)
1082
+ p.renderWc = null;
1083
+ if (state.wcIdToBridgeId.get(sender.id) === bridgeId)
1084
+ state.wcIdToBridgeId.delete(sender.id);
1085
+ });
1086
+ }
1087
+ return page;
1088
+ }
1089
+ function pageFromMsg(state, ap, msg) {
1090
+ const target = readBridgeId(msg);
1091
+ if (!target)
1092
+ return undefined;
1093
+ const page = ap.pages.get(target);
1094
+ return page;
1095
+ }
1096
+ function appByWc(state, wc) {
1097
+ if (wc.isDestroyed())
1098
+ return undefined;
1099
+ const appSessionId = state.wcIdToAppSessionId.get(wc.id);
1100
+ if (appSessionId)
1101
+ return state.appSessions.get(appSessionId);
1102
+ const bridgeId = state.wcIdToBridgeId.get(wc.id);
1103
+ if (bridgeId) {
1104
+ const page = state.pageSessions.get(bridgeId);
1105
+ if (page)
1106
+ return state.appSessions.get(page.appSessionId);
1107
+ }
1108
+ return undefined;
1109
+ }
1110
+ function resolveAppByBridgeId(state, bridgeId) {
1111
+ if (state.appSessions.has(bridgeId))
1112
+ return state.appSessions.get(bridgeId);
1113
+ const page = state.pageSessions.get(bridgeId);
1114
+ if (page)
1115
+ return state.appSessions.get(page.appSessionId);
1116
+ return undefined;
1117
+ }
1118
+ function bindWc(map, wc, id) {
1119
+ if (wc.isDestroyed())
1120
+ return;
1121
+ map.set(wc.id, id);
1122
+ }
1123
+ function readBridgeId(msg) {
1124
+ return typeof msg.body?.bridgeId === 'string' ? msg.body.bridgeId : undefined;
1125
+ }
1126
+ function newBridgeId() {
1127
+ return `bridge_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1128
+ }
1129
+ function resolveSimulatorWebContents(ctx, simulatorWcId, sender) {
1130
+ if (!simulatorWcId)
1131
+ return sender ?? ctx.windows.mainWindow.webContents;
1132
+ return webContents.fromId(simulatorWcId) ?? ctx.windows.mainWindow.webContents;
1133
+ }
1134
+ // ── Dispose ─────────────────────────────────────────────────────────────────
1135
+ function disposePageSession(state, ap, page) {
1136
+ if (page.renderWc && !page.renderWc.isDestroyed()) {
1137
+ state.wcIdToBridgeId.delete(page.renderWc.id);
1138
+ }
1139
+ ap.pages.delete(page.bridgeId);
1140
+ state.pageSessions.delete(page.bridgeId);
1141
+ }
1142
+ async function disposeAppSession(state, appSessionId, opts = {}) {
1143
+ const ap = state.appSessions.get(appSessionId);
1144
+ if (!ap)
1145
+ return;
1146
+ state.appSessions.delete(appSessionId);
1147
+ // Drain any pending API calls owned by this app session. One-shot calls
1148
+ // normally self-clean on response/timeout, but persistent (`keep: true`)
1149
+ // subscriptions (e.g. audioListen) live with their timer cleared until
1150
+ // teardown — without this they would leak in pendingApiCalls forever.
1151
+ for (const [requestId, pending] of state.pendingApiCalls) {
1152
+ if (pending.appSessionId !== appSessionId)
1153
+ continue;
1154
+ clearTimeout(pending.timer);
1155
+ state.pendingApiCalls.delete(requestId);
1156
+ }
1157
+ for (const page of ap.pages.values()) {
1158
+ if (page.renderWc && !page.renderWc.isDestroyed()) {
1159
+ state.wcIdToBridgeId.delete(page.renderWc.id);
1160
+ }
1161
+ state.pageSessions.delete(page.bridgeId);
1162
+ }
1163
+ ap.pages.clear();
1164
+ if (ap.poolEntryId !== null && state.pool && !opts.serviceAlreadyClosed) {
1165
+ // Soft reuse (foundation.md §4.3): the pooled service-host webContents keeps
1166
+ // its wc.id but is about to run a NEW app session. Reset its Connection
1167
+ // BEFORE returning it to the pool — dispose this session's owned segment
1168
+ // (the serviceWc binding cleanup) and swap a fresh one — so an old in-flight
1169
+ // response can't bleed into the next session and the next spawn `acquire`s a
1170
+ // clean segment. The connection object stays alive (it's reused), so this is
1171
+ // reset (not close); a real destroy still closes it via its 'destroyed' hook.
1172
+ state.connections.reset(ap.serviceWc.id);
1173
+ // Return the window to the pool instead of closing it. Detach the per-spawn
1174
+ // listeners first so the pool resetting/recycling (or a later pool-side
1175
+ // destroy) can't re-trigger disposeAppSession or boot this disposed session
1176
+ // into the next spawn's recycled window.
1177
+ if (ap.onServiceClosed && !ap.serviceWindow.isDestroyed()) {
1178
+ ap.serviceWindow.removeListener('closed', ap.onServiceClosed);
1179
+ }
1180
+ if (ap.onServiceBoot && !ap.serviceWindow.isDestroyed()) {
1181
+ ap.serviceWindow.webContents.removeListener('did-finish-load', ap.onServiceBoot);
1182
+ }
1183
+ await state.pool.release(ap.poolEntryId, ap.serviceWindow).catch((error) => {
1184
+ console.warn('[bridge-router] pool release failed:', error);
1185
+ });
1186
+ }
1187
+ else if (ap.poolEntryId !== null && state.pool && opts.serviceAlreadyClosed) {
1188
+ // The pooled service window already closed/crashed externally (its 'closed'
1189
+ // fired → onServiceClosed → here). `release` is skipped on this path, and the
1190
+ // pool only auto-reclaims on render-process-gone — so without this the in-use
1191
+ // entry leaks in the pool forever, permanently shrinking capacity. Reclaim
1192
+ // the slot explicitly (the window is already gone; releaseDestroyed won't
1193
+ // touch it). (Audit A1)
1194
+ state.pool.releaseDestroyed(ap.poolEntryId);
1195
+ }
1196
+ else if (!opts.serviceAlreadyClosed && !ap.serviceWindow.isDestroyed()) {
1197
+ ap.serviceWindow.close();
1198
+ }
1199
+ state.wcIdToAppSessionId.delete(ap.serviceWc.id);
1200
+ state.wcIdToAppSessionId.delete(ap.simulatorWc.id);
1201
+ // Only the local fallback server needs closing; the dev-server base is owned
1202
+ // by the workspace session, not this app session.
1203
+ if (ap.resourceServer) {
1204
+ await ap.resourceServer.close().catch((error) => {
1205
+ console.warn('[bridge-router] resource server close failed:', error);
1206
+ });
1207
+ }
1208
+ }
1209
+ // ── App-config / manifest parsing ───────────────────────────────────────────
1210
+ async function loadAppConfig(resourceBase) {
1211
+ // `resourceBase` is the dir/URL that directly contains `app-config.json`:
1212
+ // the dev server's `<base><appId>/<root>/` (http) or the local fallback
1213
+ // server root (also http). Both are HTTP, so a single fetch path covers them.
1214
+ const cfgUrl = new URL('app-config.json', resourceBase.endsWith('/') ? resourceBase : `${resourceBase}/`).toString();
1215
+ try {
1216
+ const res = await fetch(cfgUrl);
1217
+ if (!res.ok) {
1218
+ console.warn(`[bridge-router] no app-config.json at ${cfgUrl} (${res.status})`);
1219
+ return {};
1220
+ }
1221
+ return await res.json();
1222
+ }
1223
+ catch (error) {
1224
+ console.warn('[bridge-router] failed to fetch/parse app-config.json:', error);
1225
+ return {};
1226
+ }
1227
+ }
1228
+ function buildAppManifest(appConfig, fallbackEntry) {
1229
+ const entry = appConfig.app?.entryPagePath || fallbackEntry;
1230
+ const pages = appConfig.app?.pages?.length ? appConfig.app.pages : [entry];
1231
+ const tabBar = appConfig.app?.tabBar && Array.isArray(appConfig.app.tabBar.list) && appConfig.app.tabBar.list.length > 0
1232
+ ? appConfig.app.tabBar
1233
+ : undefined;
1234
+ return { entryPagePath: normalizePagePath(entry), pages: pages.map(normalizePagePath), tabBar };
1235
+ }
1236
+ function resolvePageWindowConfig(appConfig, pagePath) {
1237
+ const normalized = normalizePagePath(pagePath);
1238
+ const appWindow = appConfig.app?.window ?? {};
1239
+ const pageEntry = appConfig.modules?.[normalized];
1240
+ const pageWindow = pageEntry?.window ?? {};
1241
+ return {
1242
+ navigationBarTitleText: pageWindow.navigationBarTitleText ?? appWindow.navigationBarTitleText ?? '',
1243
+ navigationBarBackgroundColor: pageWindow.navigationBarBackgroundColor ?? appWindow.navigationBarBackgroundColor ?? '#ffffff',
1244
+ navigationBarTextStyle: pageWindow.navigationBarTextStyle ?? appWindow.navigationBarTextStyle ?? 'black',
1245
+ navigationStyle: pageWindow.navigationStyle ?? appWindow.navigationStyle ?? 'default',
1246
+ homeButton: pageWindow.homeButton ?? appWindow.homeButton,
1247
+ backgroundColor: pageWindow.backgroundColor ?? appWindow.backgroundColor,
1248
+ backgroundTextStyle: pageWindow.backgroundTextStyle ?? appWindow.backgroundTextStyle,
1249
+ enablePullDownRefresh: pageWindow.enablePullDownRefresh ?? appWindow.enablePullDownRefresh,
1250
+ disableScroll: pageWindow.disableScroll ?? appWindow.disableScroll,
1251
+ };
1252
+ }
1253
+ function isTabPage(appConfig, pagePath) {
1254
+ const list = appConfig.app?.tabBar?.list ?? [];
1255
+ const normalized = normalizePagePath(pagePath);
1256
+ return list.some(item => normalizePagePath(item.pagePath) === normalized);
1257
+ }
1258
+ function normalizePagePath(p) {
1259
+ return p ? p.replace(/^\/+/, '') : '';
1260
+ }
1261
+ // ── Protocol handlers ───────────────────────────────────────────────────────
1262
+ function installResourceProtocolHandlers(ctx, state) {
1263
+ const handler = async (request) => {
1264
+ const url = new URL(request.url);
1265
+ const ap = resolveAppByBridgeId(state, url.hostname);
1266
+ if (!ap)
1267
+ return new Response('Bridge session not found', { status: 404 });
1268
+ const target = new URL(url.pathname.replace(/^\/+/, '') + url.search, ap.resourceBaseUrl);
1269
+ return fetch(target);
1270
+ };
1271
+ const simulatorSession = electronSession.fromPartition(SHARED_MINIAPP_PARTITION);
1272
+ try {
1273
+ protocol.unhandle('dmb-resource');
1274
+ }
1275
+ catch { }
1276
+ try {
1277
+ simulatorSession.protocol.unhandle('dmb-resource');
1278
+ }
1279
+ catch { }
1280
+ protocol.handle('dmb-resource', handler);
1281
+ simulatorSession.protocol.handle('dmb-resource', handler);
1282
+ // Per-project partition sessions need the SAME resource handler so each
1283
+ // project's render/service can load `dmb-resource://…`. Install on every
1284
+ // miniapp partition (current + future); track installed sessions for teardown.
1285
+ const perProjectSessions = new Set();
1286
+ const unregisterConfigurator = registerMiniappSessionConfigurator((sess) => {
1287
+ if (perProjectSessions.has(sess))
1288
+ return;
1289
+ perProjectSessions.add(sess);
1290
+ try {
1291
+ sess.protocol.unhandle('dmb-resource');
1292
+ }
1293
+ catch { }
1294
+ sess.protocol.handle('dmb-resource', handler);
1295
+ });
1296
+ ctx.registry.add(() => {
1297
+ unregisterConfigurator();
1298
+ try {
1299
+ protocol.unhandle('dmb-resource');
1300
+ }
1301
+ catch { }
1302
+ try {
1303
+ simulatorSession.protocol.unhandle('dmb-resource');
1304
+ }
1305
+ catch { }
1306
+ for (const sess of perProjectSessions) {
1307
+ try {
1308
+ sess.protocol.unhandle('dmb-resource');
1309
+ }
1310
+ catch { }
1311
+ }
1312
+ });
1313
+ }
1314
+ function makeHostEnv(snapshot) {
1315
+ return {
1316
+ brand: 'devtools',
1317
+ model: 'Electron',
1318
+ platform: process.platform,
1319
+ system: `${process.platform} ${process.versions.electron || ''}`.trim(),
1320
+ version: process.versions.electron || '0.0.0',
1321
+ SDKVersion: 'native-host-phase-1',
1322
+ pixelRatio: 2,
1323
+ screenWidth: 390,
1324
+ screenHeight: 844,
1325
+ windowWidth: 390,
1326
+ windowHeight: 844,
1327
+ statusBarHeight: 24,
1328
+ language: app.getLocale(),
1329
+ theme: 'light',
1330
+ ...snapshot,
1331
+ };
1332
+ }
1333
+ //# sourceMappingURL=bridge-router.js.map