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

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