@dimina-kit/devtools 0.4.0-dev.20260729062524 → 0.4.0-dev.20260801163345

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 (104) hide show
  1. package/dist/main/app/app.js +7 -0
  2. package/dist/main/app/bootstrap.d.ts +8 -5
  3. package/dist/main/app/bootstrap.js +15 -15
  4. package/dist/main/index.bundle.js +2207 -5152
  5. package/dist/main/ipc/app-lifecycle.d.ts +1 -31
  6. package/dist/main/ipc/app-lifecycle.js +1 -53
  7. package/dist/main/ipc/bridge-router.d.ts +19 -152
  8. package/dist/main/ipc/bridge-router.js +28 -2186
  9. package/dist/main/ipc/page-scroll.d.ts +1 -13
  10. package/dist/main/ipc/page-scroll.js +1 -16
  11. package/dist/main/ipc/session-listener-bag.d.ts +1 -40
  12. package/dist/main/ipc/session-listener-bag.js +1 -39
  13. package/dist/main/services/console-forward/index.d.ts +1 -60
  14. package/dist/main/services/console-forward/index.js +1 -225
  15. package/dist/main/services/console-forward/internal-log.d.ts +1 -10
  16. package/dist/main/services/console-forward/internal-log.js +1 -22
  17. package/dist/main/services/diagnostics/index.d.ts +1 -58
  18. package/dist/main/services/diagnostics/index.js +1 -60
  19. package/dist/main/services/dimina-resource-server.d.ts +1 -6
  20. package/dist/main/services/dimina-resource-server.js +1 -98
  21. package/dist/main/services/mcp/target-manager.js +5 -1
  22. package/dist/main/services/network-forward/index.d.ts +15 -3
  23. package/dist/main/services/network-forward/index.js +30 -4
  24. package/dist/main/services/network-forward/websocket.d.ts +36 -0
  25. package/dist/main/services/network-forward/websocket.js +120 -0
  26. package/dist/main/services/service-host-pool/pool.d.ts +1 -196
  27. package/dist/main/services/service-host-pool/pool.js +1 -363
  28. package/dist/main/services/simulator/custom-apis.d.ts +1 -42
  29. package/dist/main/services/simulator/custom-apis.js +1 -46
  30. package/dist/main/services/simulator/referer.d.ts +1 -9
  31. package/dist/main/services/simulator/referer.js +1 -19
  32. package/dist/main/services/simulator-storage/service-storage-ops.d.ts +1 -39
  33. package/dist/main/services/simulator-storage/service-storage-ops.js +1 -66
  34. package/dist/main/services/simulator-temp-files/disk.d.ts +1 -54
  35. package/dist/main/services/simulator-temp-files/disk.js +1 -237
  36. package/dist/main/services/simulator-temp-files/fs-channels.d.ts +1 -68
  37. package/dist/main/services/simulator-temp-files/fs-channels.js +1 -107
  38. package/dist/main/services/simulator-temp-files/index.d.ts +1 -32
  39. package/dist/main/services/simulator-temp-files/index.js +1 -181
  40. package/dist/main/services/simulator-temp-files/request-handler.d.ts +1 -29
  41. package/dist/main/services/simulator-temp-files/request-handler.js +1 -177
  42. package/dist/main/services/simulator-temp-files/resolver.d.ts +1 -21
  43. package/dist/main/services/simulator-temp-files/resolver.js +1 -21
  44. package/dist/main/services/simulator-temp-files/store.d.ts +1 -13
  45. package/dist/main/services/simulator-temp-files/store.js +1 -23
  46. package/dist/main/services/views/miniapp-partition.d.ts +1 -89
  47. package/dist/main/services/views/miniapp-partition.js +1 -158
  48. package/dist/main/services/views/simulator-session-policy.d.ts +1 -27
  49. package/dist/main/services/views/simulator-session-policy.js +1 -98
  50. package/dist/main/services/workbench-context.d.ts +4 -1
  51. package/dist/main/services/workbench-context.js +17 -0
  52. package/dist/main/utils/ipc-registry.d.ts +21 -3
  53. package/dist/main/utils/ipc-registry.js +26 -23
  54. package/dist/main/utils/paths.d.ts +14 -0
  55. package/dist/main/utils/paths.js +14 -0
  56. package/dist/main/windows/service-host-window/create.d.ts +1 -92
  57. package/dist/main/windows/service-host-window/create.js +1 -154
  58. package/dist/native-host/common/common.js +154 -58
  59. package/dist/native-host/container/pageFrame.css +1 -1
  60. package/dist/native-host/render/render.js +6611 -3312
  61. package/dist/native-host/service/service.js +2 -2
  62. package/dist/preload/index.d.ts +2 -0
  63. package/dist/preload/index.js +1 -0
  64. package/dist/preload/runtime/clipboard.d.ts +6 -0
  65. package/dist/preload/runtime/clipboard.js +12 -0
  66. package/dist/preload/runtime/native-host.d.ts +2 -0
  67. package/dist/preload/runtime/native-host.js +5 -10
  68. package/dist/preload/runtime/node-bindings.d.ts +18 -0
  69. package/dist/preload/runtime/node-bindings.js +44 -0
  70. package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
  71. package/dist/preload/windows/main.cjs +1195 -897
  72. package/dist/preload/windows/main.cjs.map +4 -4
  73. package/dist/preload/windows/simulator.cjs +149 -60
  74. package/dist/preload/windows/simulator.cjs.map +4 -4
  75. package/dist/preload/windows/simulator.js +149 -60
  76. package/dist/render-host/pageFrame.html +3 -3
  77. package/dist/shared/bridge-channels.d.ts +1 -419
  78. package/dist/shared/bridge-channels.js +1 -89
  79. package/dist/shared/dmb-resource-url.d.ts +2 -0
  80. package/dist/shared/dmb-resource-url.js +2 -0
  81. package/dist/shared/ipc-channels.d.ts +2 -32
  82. package/dist/shared/ipc-channels.js +2 -5
  83. package/dist/shared/node-bindings.d.ts +2 -0
  84. package/dist/shared/node-bindings.js +2 -0
  85. package/dist/shared/request-core.d.ts +1 -83
  86. package/dist/shared/request-core.js +1 -167
  87. package/dist/simulator/assets/device-shell-Cw7c-IkG.js +2 -0
  88. package/dist/simulator/assets/{device-shell-BHz1v3Yo.css → device-shell-DbC5ccs8.css} +1 -1
  89. package/dist/simulator/assets/{jsx-runtime-hBv-rHqQ.js → jsx-runtime-DkKmJyBb.js} +2 -2
  90. package/dist/simulator/assets/simulator-COIE6R49.js +10 -0
  91. package/dist/simulator/assets/simulator-mini-app-BLn7XV8e.js +2 -0
  92. package/dist/simulator/simulator.html +2 -2
  93. package/dist/simulator/temp-files.d.ts +29 -0
  94. package/dist/simulator/temp-files.js +79 -0
  95. package/dist/simulator/types.d.ts +43 -0
  96. package/dist/simulator/types.js +2 -0
  97. package/package.json +6 -5
  98. package/dist/shared/simulator-api-metadata.d.ts +0 -26
  99. package/dist/shared/simulator-api-metadata.js +0 -59
  100. package/dist/shared/vpath.d.ts +0 -42
  101. package/dist/shared/vpath.js +0 -105
  102. package/dist/simulator/assets/device-shell-CH_DMYaz.js +0 -2
  103. package/dist/simulator/assets/simulator-DXraYerh.js +0 -10
  104. package/dist/simulator/assets/simulator-mini-app-w1Bhwkld.js +0 -2
@@ -1,2191 +1,33 @@
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 { apiCallWatchdogMs, isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js';
6
- import { devtoolsPackageRoot } from '../utils/paths.js';
7
- import { createSessionListenerBag } from './session-listener-bag.js';
8
- import { createDebugTap } from '@dimina-kit/electron-deck/main';
9
- import { startDiminaResourceServer } from '../services/dimina-resource-server.js';
10
- import { buildServiceHostSpawnUrl, createServiceHostWindow, navigateServiceHost, serviceHostSpec, } from '../windows/service-host-window/create.js';
11
- import { ServiceHostPool } from '../services/service-host-pool/pool.js';
12
- import { registerMiniappSessionConfigurator, SHARED_MINIAPP_PARTITION, } from '../services/views/miniapp-partition.js';
13
- import { createConsoleForwarder } from '../services/console-forward/index.js';
14
- import { createDiagnosticsBus } from '../services/diagnostics/index.js';
15
- import { STORAGE_API_NAMES } from '../services/simulator-storage/index.js';
16
- import { buildPageScrollScript } from './page-scroll.js';
17
- import { createAppLifecycleController, } from './app-lifecycle.js';
18
- // The compiled `logic.js` ships a RELATIVE `//# sourceMappingURL=logic.js.map`.
19
- // `injectLogicBundle` loads it via `executeJavaScript`, which gives the injected
20
- // script no base URL of its own — so DevTools resolves that relative map against
21
- // the service-host DOCUMENT and 404s, leaving console frames / Sources links
22
- // pointing at the compiled bundle instead of the developer's source. This rewrite
23
- // turns the directive absolute.
24
- //
25
- // Inlined (not imported from `service-host/sourcemap-rewrite.cjs`) ON PURPOSE:
26
- // the shipped main entry is the FLAT esbuild bundle (`dist/main/index.bundle.js`,
27
- // see electron-builder.yml `extraMetadata.main`). A runtime `createRequire(...)`
28
- // of the relative `.cjs` resolved against `import.meta.url`, which is the bundle
29
- // at `dist/main/` (one level shallower than the tsc tree `dist/main/ipc/`), so
30
- // the path 404'd and the PACKAGED app crashed on load. Bundling the logic inline
31
- // is bundler-proof. KEEP IN SYNC with `service-host/sourcemap-rewrite.cjs` (the
32
- // copied-not-bundled service-host preload still requires that one).
33
- function rewriteSourceMappingUrl(source, scriptUrl) {
34
- if (typeof source !== 'string' || !source)
35
- return source;
36
- const re = /(^|\n)[ \t]*\/\/[#@][ \t]*sourceMappingURL=([^\n]*)/g;
37
- let lastIndex = -1;
38
- let lastValue = '';
39
- let m;
40
- while ((m = re.exec(source)) !== null) {
41
- lastIndex = m.index + m[1].length;
42
- lastValue = (m[2] || '').trim();
43
- }
44
- if (lastIndex < 0 || !lastValue)
45
- return source;
46
- // Already absolute (scheme://, protocol-relative //host, or data:)? Leave it.
47
- if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(lastValue) || /^data:/i.test(lastValue))
48
- return source;
49
- let absolute;
50
- try {
51
- absolute = new URL(lastValue, scriptUrl).toString();
52
- }
53
- catch {
54
- return source;
55
- }
56
- return `${source.slice(0, lastIndex)}\n//# sourceMappingURL=${absolute}`;
57
- }
58
- const STACK_ID = 'stack_0';
1
+ export * from '@dimina-kit/electron-runtime/main/bridge-router';
2
+ import { installBridgeRouter as installRuntimeBridgeRouter } from '@dimina-kit/electron-runtime/main/bridge-router';
3
+ import { createRuntimeEvents, } from '@dimina-kit/electron-runtime/main/runtime-context';
4
+ import { runtimeAssetPaths } from '../utils/paths.js';
59
5
  /**
60
- * Watchdog window a spawned session gets to reach `'running'` (root page
61
- * `domReady`) before `handleSpawn`'s launch timer reports `'launch-failed'`
62
- * with `code: 'timeout'`. Exported so tests can assert against the real
63
- * constant instead of a magic number.
6
+ * DevTools compatibility assembly. New contexts already own RuntimeEvents;
7
+ * narrow historical test/downstream contexts are upgraded here so the runtime
8
+ * package itself stays unaware of panels and renderer notifications.
64
9
  */
65
- export const LAUNCH_TIMEOUT_MS = 20_000;
66
- /** Hard ceiling on the pre-warm pool, mirroring ServiceHostPool/prewarm-webview.md. */
67
- const PREWARM_MAX_POOL_SIZE = 4;
68
- /**
69
- * Resolve the pre-warm pool size from env (see prewarm-webview.md's config
70
- * section). Returns 0 (OFF) unless
71
- * `DIMINA_PREWARM_POOL_SIZE` is a positive integer and `DIMINA_PREWARM_DISABLE`
72
- * is not set. Default OFF — pooling is opt-in so the spawn path is unchanged
73
- * unless explicitly enabled.
74
- */
75
- function resolvePrewarmPoolSize() {
76
- if (process.env.DIMINA_PREWARM_DISABLE === '1')
77
- return 0;
78
- const raw = process.env.DIMINA_PREWARM_POOL_SIZE;
79
- if (!raw)
80
- return 0;
81
- const n = Number.parseInt(raw, 10);
82
- if (!Number.isFinite(n) || n <= 0)
83
- return 0;
84
- return Math.min(n, PREWARM_MAX_POOL_SIZE);
85
- }
86
- /** debugTap (see foundation.md) is opt-in via `DIMINA_DEBUG_TAP=1` — off everywhere else. */
87
- function resolveDebugTapEnabled() {
88
- return process.env.DIMINA_DEBUG_TAP === '1';
89
- }
90
- /**
91
- * Compact one-line summary of a bridge payload for debugTap — the inner
92
- * `msg.type` + `bridgeId`, never the full (potentially large) payload blob.
93
- */
94
- function summarizeBridgeMsg(payload) {
95
- if (!payload || typeof payload !== 'object')
96
- return undefined;
97
- const p = payload;
98
- const bridgeId = typeof p.bridgeId === 'string' ? p.bridgeId : undefined;
99
- const msg = p.msg;
100
- const type = msg && typeof msg.type === 'string' ? msg.type : undefined;
101
- const parts = [type, bridgeId ? `bridge=${bridgeId}` : undefined].filter(Boolean);
102
- return parts.length > 0 ? parts.join(' ') : undefined;
103
- }
104
- // Same-appId matches prefer the MOST RECENT spawn (Maps preserve insertion
105
- // order): after a respawn/reopen the newest session is the live one — the
106
- // first match could be a just-superseded session mid-teardown. The loop must
107
- // scan every session (no early break) so the LAST inserted match wins.
108
- function findAppSessionByAppId(state, appId) {
109
- let match;
110
- for (const ap of state.appSessions.values())
111
- if (ap.appId === appId)
112
- match = ap;
113
- return match;
114
- }
115
- // No appId hint and no workspace session to disambiguate. Picking by appId is
116
- // impossible, so fall back to the most-recent spawn — but ONLY when every live
117
- // session belongs to the same app. Same-appId multiples are a respawn/reopen
118
- // in progress where the newest is the live one (insertion order = spawn
119
- // order). Multiple DISTINCT appIds mean a previous project is mid-teardown
120
- // alongside the new one; any pick is a guess that can resolve the WRONG
121
- // project's content (the screenshot/inspect-stale-app bug class), so prefer
122
- // null over a wrong guess.
123
- function resolveFallbackAppSession(state) {
124
- let last;
125
- const distinctAppIds = new Set();
126
- for (const ap of state.appSessions.values()) {
127
- last = ap;
128
- distinctAppIds.add(ap.appId);
129
- }
130
- return distinctAppIds.size <= 1 ? last : undefined;
131
- }
132
- /**
133
- * Resolve the "current" app session: prefer an explicit appId, else the
134
- * workspace's active project, else the only / most-recently-created session.
135
- * Native-host can host multiple AppSessions, so this picks one deterministically
136
- * rather than assuming a single session.
137
- */
138
- function resolveCurrentApp(state, ctx, appId) {
139
- if (appId) {
140
- const match = findAppSessionByAppId(state, appId);
141
- if (match)
142
- return match;
143
- }
144
- const appInfo = ctx.workspace?.getSession?.()?.appInfo;
145
- const activeAppId = appInfo?.appId;
146
- if (activeAppId) {
147
- const match = findAppSessionByAppId(state, activeAppId);
148
- if (match)
149
- return match;
150
- }
151
- // During a project close the workspace nulls its session BEFORE the bridge app
152
- // session is torn down (disposeSession runs before disposeAll). Resolving here
153
- // would hand a consumer the closing project's dying guest, so refuse to guess
154
- // while a close is in flight.
155
- if (ctx.workspace?.isClosing?.())
156
- return undefined;
157
- return resolveFallbackAppSession(state);
158
- }
159
10
  export function installBridgeRouter(ctx) {
160
- const state = {
161
- appSessions: new Map(),
162
- pageSessions: new Map(),
163
- serviceWcIdToAppSessionId: new Map(),
164
- simulatorWcIdToAppSessionIds: new Map(),
165
- wcIdToBridgeId: new Map(),
166
- pendingApiCalls: new Map(),
167
- pool: null,
168
- emitRenderEvent: () => { },
169
- emitServiceHostReady: () => { },
170
- lastServiceHostReady: null,
171
- connections: ctx.connections,
172
- debugTap: createDebugTap({ enabled: resolveDebugTapEnabled() }),
173
- appLifecycle: createAppLifecycleController(),
174
- evictAppDataBridges: (ap) => {
175
- if (!ctx.appData)
176
- return;
177
- for (const page of ap.pages.values())
178
- ctx.appData.evictBridge(ap.appId, page.bridgeId);
179
- },
180
- };
181
- // Opt-in (default OFF) pre-warm pool for service-host windows. When enabled,
182
- // handleSpawn acquires a warm window instead of constructing one per spawn.
183
- const prewarmPoolSize = resolvePrewarmPoolSize();
184
- if (prewarmPoolSize > 0) {
185
- const pool = new ServiceHostPool();
186
- state.pool = pool;
187
- // Defer warm-up off the cold-start critical path (see prewarm-webview.md:
188
- // app.ready + idle). Warming eagerly here races main-window startup — it creates an
189
- // extra hidden BrowserWindow before the workbench renderer has settled,
190
- // which made the e2e's first-window/preload-readiness flaky. The timer is
191
- // cancelled on teardown so it can't warm a disposed pool.
192
- const warmTimer = setTimeout(() => {
193
- void pool
194
- .init({ defaultPoolSize: prewarmPoolSize, defaultSpec: serviceHostSpec(), maxPoolSize: PREWARM_MAX_POOL_SIZE })
195
- .catch((error) => {
196
- console.warn('[bridge-router] webview pool warm-up failed:', error);
197
- });
198
- }, 500);
199
- ctx.registry.add(() => clearTimeout(warmTimer));
200
- ctx.registry.add(() => state.pool?.dispose());
201
- }
202
- installAppLifecycleDriver(ctx, state);
203
- installResourceProtocolHandlers(ctx, state);
204
- // The currently-selected device (renderer toolbar). Cached here so it can ride
205
- // the NATIVE_HOST_ENABLED reply (race-free DeviceShell init) and so the
206
- // safe-area service can read it when a render-host guest attaches.
207
- let currentDevice = null;
208
- // Native-host enablement query. The simulator webview's preload can't read the
209
- // launch `process.env` (and additionalArguments don't reach webview guests),
210
- // nor can it compute file paths (no node:path/url in the guest preload), so it
211
- // asks main synchronously at install time for the flag + the render-host URLs.
212
- const onNativeHostQuery = (event) => {
213
- // native-host is the sole runtime: always reply enabled with the render-host
214
- // file:// URLs the simulator preload needs. `device` rides along so the
215
- // DeviceShell mounts with the right bezel size synchronously (the renderer
216
- // pushes SetDeviceInfo before AttachNative, so the cache is populated by the
217
- // time the simulator preload runs this sendSync).
218
- const reply = {
219
- enabled: true,
220
- renderHostHtmlUrl: pathToFileURL(path.join(devtoolsPackageRoot, 'dist/render-host/pageFrame.html')).toString(),
221
- renderPreloadUrl: pathToFileURL(path.join(devtoolsPackageRoot, 'dist/render-host/preload.cjs')).toString(),
222
- device: currentDevice ?? undefined,
223
- };
224
- event.returnValue = reply;
225
- };
226
- ipcMain.on(C.NATIVE_HOST_ENABLED, onNativeHostQuery);
227
- ctx.registry.add(() => { ipcMain.removeListener(C.NATIVE_HOST_ENABLED, onNativeHostQuery); });
228
- // Subscribers to render-side activity (domReady / active-page). Panels that
229
- // pull from the active render guest (WXML) re-read on these.
230
- const renderEventListeners = new Set();
231
- const emitRenderEvent = (event) => {
232
- for (const listener of renderEventListeners) {
233
- try {
234
- listener(event);
235
- }
236
- catch (error) {
237
- console.warn('[bridge-router] render-event listener threw:', error);
238
- }
239
- }
240
- };
241
- state.emitRenderEvent = emitRenderEvent;
242
- // Subscribers to `ServiceHostReadyEvent` (see its doc comment). Separate
243
- // from `renderEventListeners`: this is session-scoped (appSessionId +
244
- // serviceWcId), not page/bridgeId-scoped like `RenderEvent`.
245
- const serviceHostReadyListeners = new Set();
246
- const emitServiceHostReady = (event) => {
247
- state.lastServiceHostReady = event;
248
- for (const listener of serviceHostReadyListeners) {
249
- try {
250
- listener(event);
251
- }
252
- catch (error) {
253
- console.warn('[bridge-router] service-host-ready listener threw:', error);
254
- }
255
- }
256
- };
257
- state.emitServiceHostReady = emitServiceHostReady;
258
- // Expose a thin accessor over RouterState so other main services (storage,
259
- // automation, appdata) can resolve live render/service WebContents without
260
- // owning router state. Getters resolve fresh — the pre-warm pool can swap
261
- // windows on respawn, so cached handles go stale.
262
- const bridgeHandle = {
263
- isNativeHost: () => true,
264
- resolveRenderWc: (bridgeId) => {
265
- const page = state.pageSessions.get(bridgeId);
266
- return page?.renderWc && !page.renderWc.isDestroyed() ? page.renderWc : null;
267
- },
268
- getServiceWc: (appId) => {
269
- const ap = resolveCurrentApp(state, ctx, appId);
270
- return ap && !ap.serviceWc.isDestroyed() ? ap.serviceWc : null;
271
- },
272
- getServiceWcForBridge: (bridgeId) => {
273
- const page = state.pageSessions.get(bridgeId);
274
- if (!page)
275
- return null;
276
- const ap = state.appSessions.get(page.appSessionId);
277
- return ap && !ap.serviceWc.isDestroyed() ? ap.serviceWc : null;
278
- },
279
- getActiveBridgeId: (appId) => {
280
- const ap = resolveCurrentApp(state, ctx, appId);
281
- if (!ap)
282
- return null;
283
- // Fall back to the root page (= appSessionId) before the first signal.
284
- return ap.activeBridgeId ?? ap.appSessionId;
285
- },
286
- getPageStack: (appId) => {
287
- const ap = resolveCurrentApp(state, ctx, appId);
288
- return ap?.pageStack ?? null;
289
- },
290
- getResourceBaseUrl: (appId) => {
291
- const ap = resolveCurrentApp(state, ctx, appId);
292
- return ap?.resourceBaseUrl ?? null;
293
- },
294
- getActiveRenderWc: (appId) => {
295
- const ap = resolveCurrentApp(state, ctx, appId);
296
- if (!ap)
297
- return null;
298
- const page = state.pageSessions.get(ap.activeBridgeId ?? ap.appSessionId);
299
- return page?.renderWc && !page.renderWc.isDestroyed() ? page.renderWc : null;
300
- },
301
- onRenderEvent: (listener) => {
302
- renderEventListeners.add(listener);
303
- return () => renderEventListeners.delete(listener);
304
- },
305
- onServiceHostReady: (listener) => {
306
- serviceHostReadyListeners.add(listener);
307
- // Missed-signal catch-up (mirrors host-toolbar-port-channel.ts's
308
- // `onReady`): a subscriber registering AFTER the session it cares
309
- // about already booted must not wait forever for an event that
310
- // already happened. Scheduled on a microtask (never synchronous —
311
- // matches the same-tick-dispose/re-attach safety the host-toolbar
312
- // pattern documents) and RE-VALIDATED at fire time: the session must
313
- // still be live and still bound to the SAME serviceWcId the event
314
- // captured, so a session that respawned/closed between registration
315
- // and the microtask can't resurrect a stale attach.
316
- const candidate = state.lastServiceHostReady;
317
- if (candidate) {
318
- queueMicrotask(() => {
319
- if (!serviceHostReadyListeners.has(listener))
320
- return;
321
- const ap = state.appSessions.get(candidate.appSessionId);
322
- if (!ap || ap.serviceWc.isDestroyed() || ap.serviceWc.id !== candidate.serviceWcId)
323
- return;
324
- try {
325
- listener(candidate);
326
- }
327
- catch (error) {
328
- console.warn('[bridge-router] service-host-ready catch-up listener threw:', error);
329
- }
330
- });
331
- }
332
- return () => serviceHostReadyListeners.delete(listener);
333
- },
334
- getDevice: () => currentDevice,
335
- setDevice: (device) => {
336
- currentDevice = device;
337
- // Push to the live simulator WC(s) so a mounted DeviceShell re-renders the
338
- // bezel/status-bar/notch. Pre-spawn there is no session yet — the initial
339
- // device rides NATIVE_HOST_ENABLED instead. Dedupe across app sessions
340
- // that share one simulator WCV.
341
- const seen = new Set();
342
- for (const ap of state.appSessions.values()) {
343
- const wc = ap.simulatorWc;
344
- if (wc && !wc.isDestroyed() && !seen.has(wc.id)) {
345
- seen.add(wc.id);
346
- wc.send(E.DEVICE_CHANGE, device);
347
- }
348
- }
349
- },
350
- disposeSessionsForSimulator: (simulatorWcId) => {
351
- // Snapshot ids first: disposeAppSession mutates state.appSessions.
352
- const ids = [];
353
- for (const [id, ap] of state.appSessions) {
354
- if (ap.simulatorWc.id === simulatorWcId)
355
- ids.push(id);
356
- }
357
- // disposeAppSession's synchronous prefix clears every map + closes the
358
- // render guests + the service window, so the bridge is clean the moment
359
- // this is called. The joined promise resolves after the async tail
360
- // (pool.release / resourceServer.close) so a caller can sequence a reopen
361
- // after full teardown. disposeAppSession logs those tail failures
362
- // internally, so this resolves rather than rejecting on them — it is a
363
- // completion signal, not an error channel.
364
- return Promise.all(ids.map((id) => disposeAppSession(state, id))).then(() => { });
365
- },
366
- debugTap: state.debugTap,
367
- census: () => {
368
- const simulatorDestroyedListeners = {};
369
- let simulatorWcBindings = 0;
370
- for (const ids of state.simulatorWcIdToAppSessionIds.values())
371
- simulatorWcBindings += ids.size;
372
- for (const ap of state.appSessions.values()) {
373
- const wc = ap.simulatorWc;
374
- if (!wc.isDestroyed() && simulatorDestroyedListeners[wc.id] === undefined) {
375
- simulatorDestroyedListeners[wc.id] = wc.listenerCount('destroyed');
376
- }
377
- }
378
- return {
379
- appSessions: state.appSessions.size,
380
- pageSessions: state.pageSessions.size,
381
- serviceWcBindings: state.serviceWcIdToAppSessionId.size,
382
- simulatorWcs: state.simulatorWcIdToAppSessionIds.size,
383
- simulatorWcBindings,
384
- renderWcBindings: state.wcIdToBridgeId.size,
385
- pendingApiCalls: state.pendingApiCalls.size,
386
- simulatorDestroyedListeners,
387
- };
388
- },
389
- };
390
- ctx.bridge = bridgeHandle;
391
- // e2e resource-census probe: Playwright's electronApp.evaluate() reads the
392
- // router's ledger straight off this main-process global — no IPC surface
393
- // needed. Test builds only.
394
- if (process.env.NODE_ENV === 'test') {
395
- ;
396
- globalThis.__diminaResourceCensus = () => bridgeHandle.census?.();
397
- }
398
- // Always-on guest console fan-out. Owns `ctx.guestConsole` (the sink the
399
- // consoleLog case below routes to) so that render-layer console output is
400
- // mirrored into the service host's own console — surfacing it in the embedded
401
- // Chrome DevTools (attached to the service host) prefixed `[视图]` — regardless
402
- // of whether an automation client is connected. Automation now SUBSCRIBES to
403
- // this forwarder instead of clobbering `ctx.guestConsole`.
404
- // Authoritative diagnostics bus (see workbench-context.ts): the single place
405
- // main-synthesized diagnostics are born. The forwarder subscribes to it so
406
- // every diagnostic also lands in the owning session's embedded DevTools
407
- // Console panel, not just the main-process log / automation subscribers.
408
- // A caller (host embedding, or a test) may have already installed one on
409
- // `ctx` before `installBridgeRouter` runs — respect it instead of
410
- // clobbering it with a fresh bus; only a bus WE create here is ours to
411
- // dispose on teardown.
412
- const ownsDiagnosticsBus = ctx.diagnostics === undefined;
413
- const diagnosticsBus = ctx.diagnostics ?? createDiagnosticsBus();
414
- ctx.diagnostics = diagnosticsBus;
415
- const consoleForwarder = createConsoleForwarder(bridgeHandle, diagnosticsBus);
416
- ctx.consoleForwarder = consoleForwarder;
417
- ctx.guestConsole = consoleForwarder;
418
- ctx.registry.add(() => {
419
- void consoleForwarder.dispose();
420
- ctx.consoleForwarder = undefined;
421
- ctx.guestConsole = undefined;
422
- if (ownsDiagnosticsBus) {
423
- diagnosticsBus.dispose();
424
- ctx.diagnostics = undefined;
425
- }
426
- });
427
- // DeviceShell → main: record the visible top-of-stack page bridgeId so the
428
- // accessor above can resolve "the active page". Sender-validated against the
429
- // app that owns it; ignored for unknown apps/pages.
430
- const onActivePage = (event, payload) => {
431
- const ap = state.appSessions.get(payload.appSessionId);
432
- if (!ap)
433
- return;
434
- if (!senderBoundToSession(state, event.sender, ap))
435
- return;
436
- if (ap.pages.has(payload.bridgeId)) {
437
- ap.activeBridgeId = payload.bridgeId;
438
- const pagePath = state.pageSessions.get(payload.bridgeId)?.pagePath;
439
- emitRenderEvent({ kind: 'activePage', appId: ap.appId, bridgeId: payload.bridgeId, pagePath });
440
- }
441
- };
442
- ipcMain.on(C.ACTIVE_PAGE, onActivePage);
443
- ctx.registry.add(() => { ipcMain.removeListener(C.ACTIVE_PAGE, onActivePage); });
444
- // DeviceShell → main: the full ordered page stack (bottom→top). Stored so
445
- // automation's App.getPageStack can report a multi-page stack (main has no
446
- // stack of its own). Sender-validated against the owning app.
447
- const onPageStack = (event, payload) => {
448
- const ap = state.appSessions.get(payload.appSessionId);
449
- if (!ap)
450
- return;
451
- if (!senderBoundToSession(state, event.sender, ap))
452
- return;
453
- ap.pageStack = payload.stack;
454
- };
455
- ipcMain.on(C.PAGE_STACK, onPageStack);
456
- ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_STACK, onPageStack); });
457
- ipcMain.handle(C.SPAWN, async (event, opts) => {
458
- return handleSpawn(state, ctx, event, opts);
459
- });
460
- ctx.registry.add(() => { ipcMain.removeHandler(C.SPAWN); });
461
- ipcMain.handle(C.PAGE_OPEN, async (event, opts) => {
462
- return handlePageOpen(state, event, opts);
463
- });
464
- ctx.registry.add(() => { ipcMain.removeHandler(C.PAGE_OPEN); });
465
- const onPageClose = (event, payload) => {
466
- handlePageClose(state, event.sender, payload);
467
- };
468
- ipcMain.on(C.PAGE_CLOSE, onPageClose);
469
- ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_CLOSE, onPageClose); });
470
- const onPageLifecycle = (event, payload) => {
471
- handlePageLifecycle(state, event.sender, payload);
472
- // Native-host AppData: evict the bridge on page teardown so the panel drops
473
- // its tab (mirrors the default path's postMessage(pageUnload) eviction).
474
- if (payload.event === 'pageUnload' && ctx.appData) {
475
- const ap = state.appSessions.get(payload.appSessionId);
476
- if (ap && senderBoundToSession(state, event.sender, ap)) {
477
- ctx.appData.evictBridge(ap.appId, payload.bridgeId);
478
- }
479
- }
480
- };
481
- ipcMain.on(C.PAGE_LIFECYCLE, onPageLifecycle);
482
- ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_LIFECYCLE, onPageLifecycle); });
483
- const onNavCallback = (event, payload) => {
484
- handleNavCallback(state, event.sender, payload);
485
- };
486
- ipcMain.on(C.NAV_CALLBACK, onNavCallback);
487
- ctx.registry.add(() => { ipcMain.removeListener(C.NAV_CALLBACK, onNavCallback); });
488
- const onDispose = (event, payload) => {
489
- const target = resolveAppByBridgeId(state, payload.bridgeId);
490
- if (!target)
491
- return;
492
- // Only the app's service window or simulator window can issue dispose.
493
- // (Unknown senders pass — lenient by design; a simulator WCV is authorized
494
- // for every session it hosts, so an older session's own dispose stays
495
- // valid while a newer spawn shares the wc.)
496
- if (!senderBoundToSession(state, event.sender, target) && appByWc(state, event.sender)) {
497
- console.warn(`[bridge-router] DISPOSE rejected: sender not bound to target ${target.appSessionId}`);
498
- return;
499
- }
500
- // AppData bridge eviction happens inside disposeAppSession (single
501
- // chokepoint shared with the simulator-WCV 'destroyed' path).
502
- void disposeAppSession(state, target.appSessionId);
503
- };
504
- ipcMain.on(C.DISPOSE, onDispose);
505
- ctx.registry.add(() => { ipcMain.removeListener(C.DISPOSE, onDispose); });
506
- // debugTap (see foundation.md) ingress recorder — near-free no-op unless DIMINA_DEBUG_TAP=1.
507
- // Hung on the bridge dispatch chokepoint so the cross-wc message flow is
508
- // inspectable (the first real consumer of the workbench debugTap primitive).
509
- const tapIn = (channel, sender, payload) => {
510
- if (!state.debugTap.enabled)
511
- return;
512
- state.debugTap.record({
513
- ts: Date.now(),
514
- channel,
515
- direction: 'in',
516
- connectionId: sender.id,
517
- appSessionId: appByWc(state, sender)?.appSessionId,
518
- summary: summarizeBridgeMsg(payload),
519
- });
520
- };
521
- const onServiceInvoke = (event, payload) => {
522
- tapIn(C.SERVICE_INVOKE, event.sender, payload);
523
- const ap = appByWc(state, event.sender);
524
- if (!ap)
525
- return;
526
- const page = state.pageSessions.get(payload.bridgeId) ?? state.pageSessions.get(ap.appSessionId);
527
- if (!page)
528
- return;
529
- routeFromService(state, ap, page, payload.msg, ctx);
530
- };
531
- ipcMain.on(C.SERVICE_INVOKE, onServiceInvoke);
532
- ctx.registry.add(() => { ipcMain.removeListener(C.SERVICE_INVOKE, onServiceInvoke); });
533
- const onServicePublish = (event, payload) => {
534
- tapIn(C.SERVICE_PUBLISH, event.sender, payload);
535
- const ap = appByWc(state, event.sender);
536
- if (!ap)
537
- return;
538
- forwardToRender(ap, payload.msg, payload.targetBridgeId);
539
- // Native-host AppData panel: tap the service→render setData stream centrally
540
- // (the simulator guest has no Worker to sniff under native-host). Cheap —
541
- // the tap ignores non-ub/non-page_* messages.
542
- ctx.appData?.onServiceToRender(ap.appId, payload.msg);
543
- };
544
- ipcMain.on(C.SERVICE_PUBLISH, onServicePublish);
545
- ctx.registry.add(() => { ipcMain.removeListener(C.SERVICE_PUBLISH, onServicePublish); });
546
- const onRenderInvoke = (event, payload) => {
547
- tapIn(C.RENDER_INVOKE, event.sender, payload);
548
- const page = ensureRenderBound(state, event.sender, payload.bridgeId);
549
- if (!page)
550
- return;
551
- const ap = state.appSessions.get(page.appSessionId);
552
- if (!ap)
553
- return;
554
- routeFromRender(state, ap, page, payload.msg, ctx);
555
- };
556
- ipcMain.on(C.RENDER_INVOKE, onRenderInvoke);
557
- ctx.registry.add(() => { ipcMain.removeListener(C.RENDER_INVOKE, onRenderInvoke); });
558
- const onRenderPublish = (event, payload) => {
559
- tapIn(C.RENDER_PUBLISH, event.sender, payload);
560
- const page = ensureRenderBound(state, event.sender, payload.bridgeId);
561
- if (!page)
562
- return;
563
- const ap = state.appSessions.get(page.appSessionId);
564
- if (!ap)
565
- return;
566
- forwardToService(ap, payload.msg);
567
- };
568
- ipcMain.on(C.RENDER_PUBLISH, onRenderPublish);
569
- ctx.registry.add(() => { ipcMain.removeListener(C.RENDER_PUBLISH, onRenderPublish); });
570
- ipcMain.handle(C.SIMULATOR_API, async (_event, payload) => {
571
- return ctx.simulatorApis.invoke(payload.name, payload.params);
572
- });
573
- ctx.registry.add(() => { ipcMain.removeHandler(C.SIMULATOR_API); });
574
- const onApiResponse = (event, payload) => {
575
- tapIn(C.API_RESPONSE, event.sender, payload);
576
- handleApiResponse(state, event.sender, payload);
577
- };
578
- ipcMain.on(C.API_RESPONSE, onApiResponse);
579
- ctx.registry.add(() => { ipcMain.removeListener(C.API_RESPONSE, onApiResponse); });
580
- ctx.registry.add(async () => {
581
- // Clear any in-flight pending API timers before tearing down sessions so a
582
- // late simulator response cannot fire a callback against a torn-down app.
583
- for (const pending of state.pendingApiCalls.values())
584
- clearTimeout(pending.timer);
585
- state.pendingApiCalls.clear();
586
- });
587
- ctx.registry.add(async () => {
588
- await Promise.all(Array.from(state.appSessions.keys()).map(id => disposeAppSession(state, id)));
589
- });
590
- }
591
- // ── Spawn / Open / Close ─────────────────────────────────────────────────────
592
- /** Clear a session's launch-timeout watchdog, if still armed. Idempotent. */
593
- function clearLaunchTimer(ap) {
594
- if (ap.launchTimer === null)
595
- return;
596
- clearTimeout(ap.launchTimer);
597
- ap.launchTimer = null;
598
- }
599
- /**
600
- * Arm the `LAUNCH_TIMEOUT_MS` watchdog for a freshly spawned session. Fires
601
- * `'launch-failed'` (`code: 'timeout'`) exactly once, and only if the session
602
- * is both still registered (not disposed) and has not already reached
603
- * `'running'` — both settle paths clear the timer themselves, but a timer
604
- * queued just before either lands could still fire on the old macrotask queue.
605
- */
606
- function startLaunchTimer(state, ctx, ap) {
607
- ap.launchTimer = setTimeout(() => {
608
- ap.launchTimer = null;
609
- if (ap.running)
610
- return;
611
- if (state.appSessions.get(ap.appSessionId) !== ap)
612
- return;
613
- const reason = `Service host did not report readiness within ${LAUNCH_TIMEOUT_MS}ms`;
614
- ctx.diagnostics?.report({
615
- severity: 'error',
616
- code: 'launch-timeout',
617
- message: reason,
618
- appSessionId: ap.appSessionId,
619
- });
620
- pushRuntimeStatus(ctx, ap, { phase: 'launch-failed', code: 'timeout', reason });
621
- }, LAUNCH_TIMEOUT_MS);
622
- }
623
- /**
624
- * Flip a session to `'running'` on its root page's first `domReady` — the
625
- * single source-of-truth transition out of `'launching'`. Guarded by
626
- * `ap.running` so a later re-render of the same root page (or any non-root
627
- * page) never re-fires the notification or re-clears an already-cleared timer.
628
- */
629
- function markSessionRunning(ctx, ap, page) {
630
- if (!page.isRoot || ap.running)
631
- return;
632
- ap.running = true;
633
- clearLaunchTimer(ap);
634
- pushRuntimeStatus(ctx, ap, { phase: 'running' });
635
- }
636
- /**
637
- * The single chokepoint for session runtime-status pushes. Every event
638
- * carries the session's launch-round facts (appId + pageFallback) alongside
639
- * the phase change — the renderer replaces its runtimeStatus wholesale, so an
640
- * event that omitted a still-true fallback would silently blank the fallback
641
- * banner the moment the phase advances past 'launching'.
642
- */
643
- function pushRuntimeStatus(ctx, session, status) {
644
- ctx.notify?.sessionRuntimeStatus?.({
645
- appId: session.appId,
646
- ...status,
647
- ...(session.pageFallback ? { pageFallback: session.pageFallback } : {}),
648
- });
649
- }
650
- async function handleSpawn(state, ctx, event, opts) {
651
- const appId = opts.appId;
652
- if (!appId)
653
- throw new Error('[bridge-router] spawn requires appId');
654
- const bridgeId = opts.bridgeId || newBridgeId();
655
- const appSessionId = bridgeId;
656
- const simulatorWc = resolveSimulatorWebContents(ctx, opts.simulatorWcId, event.sender);
657
- const pagePath = normalizePagePath(opts.pagePath || 'pages/index/index');
658
- const workspaceProjectPath = typeof ctx.workspace.getProjectPath === 'function'
659
- ? ctx.workspace.getProjectPath()
660
- : '';
661
- const pkgRoot = path.resolve(opts.pkgRoot || workspaceProjectPath || process.cwd());
662
- const root = opts.root || 'main';
663
- // Host-config custom API namespaces (WorkbenchContext is the single owner).
664
- // Threaded into the service-host spawn URL so its preload can install the
665
- // namespace globals; the simulator-supplied `opts.apiNamespaces` is derived
666
- // from the same host config and is not authoritative here.
667
- const apiNamespaces = ctx.apiNamespaces ?? [];
668
- // Resource base resolution. Preferred: the simulator-supplied dev-server
669
- // origin, which statically serves the compiled `<appId>/<root>/…` tree (same
670
- // source the default dimina-fe `<webview>` reads). Fallback (no dev server —
671
- // legacy/unit tests): a local server rooted at `pkgRoot/root`. Either way
672
- // `resourceBaseUrl` is the single base all fetches resolve against.
673
- let resourceServer = null;
674
- let resourceBaseUrl;
675
- if (opts.resourceBaseUrl) {
676
- resourceBaseUrl = opts.resourceBaseUrl.endsWith('/') ? opts.resourceBaseUrl : `${opts.resourceBaseUrl}/`;
677
- }
678
- else {
679
- resourceServer = await startDiminaResourceServer(path.resolve(pkgRoot, root));
680
- resourceBaseUrl = resourceServer.baseUrl;
681
- }
682
- // The selected device (renderer toolbar) is the authoritative source for the
683
- // logical dims a spawn must report. The simulator-supplied `hostEnvSnapshot`
684
- // is derived from the device baked into the simulator at BOOT time, so on a
685
- // RESPAWN after a live device change it still carries the boot device. Layer
686
- // the live `currentDevice` on top so every spawn/respawn reports the selected
687
- // device — matching what the live `SetDeviceInfo` HostEnvUpdate pushes to an
688
- // already-running service host. Pre-selection (null) → simulator snapshot wins.
689
- const selectedDevice = ctx.bridge?.getDevice?.() ?? null;
690
- const hostEnv = makeHostEnv({
691
- ...opts.hostEnvSnapshot,
692
- ...(selectedDevice ? deviceInfoToHostEnv(selectedDevice) : {}),
693
- });
694
- // app-config.json lives at `<base><appId>/<root>/app-config.json` on the dev
695
- // server, or at the local server root for the fallback path.
696
- const appConfig = await loadAppConfig(resourceServer ? resourceServer.baseUrl : `${resourceBaseUrl}${appId}/${root}/`, ({ url, error }) => {
697
- ctx.diagnostics?.report({
698
- severity: 'error',
699
- code: 'app-config-unreachable',
700
- message: `app-config.json unreachable at ${url}: ${String(error)}`,
701
- appSessionId,
702
- });
703
- });
704
- const manifest = buildAppManifest(appConfig, pagePath);
705
- const { resolvedPagePath, pageFallbackApplied } = resolveRootPagePath(manifest, pagePath);
706
- if (pageFallbackApplied) {
707
- reportPageNotFound(ctx, appSessionId, pagePath, resolvedPagePath);
708
- }
709
- const rootWindowConfig = resolvePageWindowConfig(appConfig, resolvedPagePath);
710
- const isTab = isTabPage(appConfig, resolvedPagePath);
711
- // Acquire a pre-warmed service-host window when pooling is enabled; otherwise
712
- // construct one fresh (default). A pooled/fallback window is warmed on
713
- // about:blank and must be navigated to the spawn URL below; the fresh path
714
- // navigates inside createServiceHostWindow.
715
- const usedPool = state.pool !== null;
716
- let poolEntryId = null;
717
- let serviceWindow;
718
- // Shared spawn-navigation failure handler (fresh AND pooled paths): without
719
- // it a loadURL rejection is fully swallowed — did-finish-load never fires and
720
- // the only signal is the launch watchdog's late timeout instead of the cause.
721
- const reportServiceHostNavigationFailed = (spawnUrl, err) => {
722
- const message = `Failed to navigate service host to ${spawnUrl}: ${String(err)}`;
723
- ctx.diagnostics?.report({
724
- severity: 'error',
725
- code: 'service-host-navigation-failed',
726
- message,
727
- appSessionId,
728
- });
729
- // The session registers after window construction; resolve it fresh so the
730
- // fresh-path callback (created pre-registration) still clears the watchdog.
731
- const ap = state.appSessions.get(appSessionId);
732
- if (ap)
733
- clearLaunchTimer(ap);
734
- pushRuntimeStatus(ctx, ap ?? { appId, pageFallback: pageFallbackApplied ? { requested: pagePath, resolved: resolvedPagePath } : null }, { phase: 'launch-failed', code: 'service-host-navigation-failed', reason: message });
735
- };
736
- if (state.pool) {
737
- const acquired = await state.pool.acquire(serviceHostSpec());
738
- serviceWindow = acquired.win;
739
- poolEntryId = acquired.entryId;
740
- }
741
- else {
742
- const freshWindowOptions = {
743
- bridgeId,
744
- appId,
745
- // Same (appId, projectPath) pair the simulator WCV uses, so this project's
746
- // service host and render guests land on ONE partition while a different
747
- // project with the same appId is isolated. Use the path captured at spawn
748
- // ENTRY (workspaceProjectPath), not a re-read: a project switch during the
749
- // awaits above would otherwise give the service host a different path than
750
- // the simulator WCV was built with, splitting the partition.
751
- projectPath: workspaceProjectPath || undefined,
752
- pagePath: resolvedPagePath,
753
- pkgRoot,
754
- root,
755
- resourceBaseUrl,
756
- hostEnvSnapshot: hostEnv,
757
- apiNamespaces,
758
- };
759
- serviceWindow = createServiceHostWindow({
760
- ...freshWindowOptions,
761
- onLoadFailed: err => reportServiceHostNavigationFailed(buildServiceHostSpawnUrl(freshWindowOptions), err),
762
- });
763
- }
764
- const appSession = {
765
- appSessionId,
766
- appId,
767
- pkgRoot,
768
- root,
769
- scene: opts.scene ?? 1001,
770
- serviceWindow,
771
- serviceWc: serviceWindow.webContents,
772
- simulatorWc,
773
- serviceLoaded: false,
774
- logicInjected: null,
775
- resourceBaseUrl,
776
- resourceServer,
777
- hostEnv,
778
- appConfig,
779
- manifest,
780
- pages: new Map(),
781
- activeBridgeId: null,
782
- poolEntryId,
783
- onServiceBoot: null,
784
- listenerBag: createSessionListenerBag(),
785
- registryHandle: null,
786
- running: false,
787
- launchTimer: null,
788
- pageFallback: pageFallbackApplied ? { requested: pagePath, resolved: resolvedPagePath } : null,
789
- };
790
- const rootPage = {
791
- bridgeId,
792
- appSessionId,
793
- pagePath: resolvedPagePath,
794
- query: opts.query ?? {},
795
- isRoot: true,
796
- isTab,
797
- renderWc: null,
798
- renderLoaded: false,
799
- resourceLoadedSent: false,
800
- renderLoadPending: false,
801
- windowConfig: rootWindowConfig,
802
- };
803
- state.appSessions.set(appSessionId, appSession);
804
- state.pageSessions.set(bridgeId, rootPage);
805
- appSession.pages.set(bridgeId, rootPage);
806
- bindWc(state.serviceWcIdToAppSessionId, serviceWindow.webContents, appSessionId);
807
- bindSimulatorWc(state.simulatorWcIdToAppSessionIds, simulatorWc, appSessionId);
808
- // Track the service-host webContents as a Connection (the soft-reuse
809
- // teardown path documented in foundation.md) and
810
- // own this session's serviceWc→appSessionId binding on its CURRENT lifetime
811
- // segment. On a pooled-window REUSE, `disposeAppSession` calls
812
- // `connections.reset(serviceWc.id)` → this owned cleanup fires + a fresh
813
- // segment opens, so the next session rebinding the same wc starts clean; on a
814
- // real destroy the connection closes and fires it too. The cleanup is guarded
815
- // by `appSessionId` so it only clears a binding that is still THIS session's.
816
- // (The simulatorWc binding stays manual in `disposeAppSession`: the simulator
817
- // wc outlives individual app sessions — cross-connection state documented in
818
- // foundation.md — so it
819
- // cannot hang off any single session's segment.)
820
- state.connections.acquire(serviceWindow.webContents).own(() => {
821
- if (state.serviceWcIdToAppSessionId.get(appSession.serviceWc.id) === appSessionId) {
822
- state.serviceWcIdToAppSessionId.delete(appSession.serviceWc.id);
823
- }
824
- });
825
- appSession.registryHandle = ctx.registry.add(() => disposeAppSession(state, appSessionId));
826
- const onServiceClosed = () => {
827
- void disposeAppSession(state, appSessionId, { serviceAlreadyClosed: true });
828
- };
829
- appSession.listenerBag.once(serviceWindow, 'closed', onServiceClosed);
830
- // The simulator WCV owns the app's UI lifetime. When it is destroyed —
831
- // project close (`views.disposeAll`/detach) or a DeviceShell respawn
832
- // (`attachNativeSimulator`, e.g. watcher hot reload) — the guest never gets
833
- // to send its graceful `C.DISPOSE`, so without this hook the app session and
834
- // its hidden service-host window leak, and `resolveCurrentApp` keeps
835
- // resolving the STALE session for the same appId: `getActiveRenderWc` then
836
- // dereferences dead pages and every panel pull (WXML/elements) returns null
837
- // for the whole next session. Disposing here is idempotent with the graceful
838
- // path (`disposeAppSession` early-returns once the session is gone).
839
- const onSimulatorDestroyed = () => {
840
- void disposeAppSession(state, appSessionId);
841
- };
842
- appSession.listenerBag.once(simulatorWc, 'destroyed', onSimulatorDestroyed);
843
- // The simulator WCV may have been torn down DURING this spawn's awaits
844
- // (loadAppConfig / resource server / service-host creation) — e.g. a project
845
- // close/switch that ran while we were mid-flight. In that case its 'destroyed'
846
- // event already fired and the once() above will never run, so the freshly
847
- // registered session would be a zombie owned by a dead simulator (the
848
- // teardown's disposeSessionsForSimulator snapshot taken before appSessions.set
849
- // could not have included it). Dispose now — idempotent with the graceful
850
- // path — so a closed/superseded simulator can't leave a resurrected session.
851
- if (simulatorWc.isDestroyed()) {
852
- void disposeAppSession(state, appSessionId);
853
- }
854
- if (usedPool) {
855
- // A pooled/fallback window passes through about:blank (warm load or the
856
- // fallback's initial load). Boot only once the REAL service.html navigation
857
- // finishes — filtering by URL so an about:blank did-finish-load can't fire a
858
- // premature boot. The listener removes itself after the first match.
859
- const bootOnServiceLoad = () => {
860
- if (serviceWindow.isDestroyed())
861
- return;
862
- // This session was disposed before its service.html settled: self-evict so
863
- // a recycled window doesn't carry a stale listener into the next spawn.
864
- if (state.appSessions.get(appSessionId) !== appSession) {
865
- serviceWindow.webContents.removeListener('did-finish-load', bootOnServiceLoad);
866
- return;
867
- }
868
- // Ignore the warm/about:blank load; only the real service.html boots.
869
- if (!serviceWindow.webContents.getURL().includes('service.html'))
870
- return;
871
- serviceWindow.webContents.removeListener('did-finish-load', bootOnServiceLoad);
872
- void bootServiceHost(state, appSession, ctx);
873
- };
874
- appSession.onServiceBoot = bootOnServiceLoad;
875
- serviceWindow.webContents.on('did-finish-load', bootOnServiceLoad);
876
- const spawnUrl = buildServiceHostSpawnUrl({
877
- bridgeId,
878
- appId,
879
- pagePath: resolvedPagePath,
880
- pkgRoot,
881
- root,
882
- resourceBaseUrl,
883
- hostEnvSnapshot: hostEnv,
884
- apiNamespaces,
885
- });
886
- void navigateServiceHost(serviceWindow, spawnUrl, {
887
- onLoadFailed: err => reportServiceHostNavigationFailed(spawnUrl, err),
888
- });
889
- }
890
- else {
891
- // Fresh window: its only navigation is service.html (issued inside
892
- // createServiceHostWindow), so the first did-finish-load is the spawn load.
893
- serviceWindow.webContents.once('did-finish-load', () => {
894
- void bootServiceHost(state, appSession, ctx);
895
- });
896
- }
897
- // Service-host crash (renderer process gone): a session-scoped hook on the
898
- // service webContents, bound via the listener bag so `disposeAppSession`
899
- // detaches it (matching the 'closed'/'destroyed' hooks registered above).
900
- // Not a `.once` — a pooled service window can recover and crash again
901
- // across its lifetime, and each crash is independently worth surfacing.
902
- // Fires whether the crash lands before or after 'running'; only the launch
903
- // timer needs clearing (it may already be null) since a running session no
904
- // longer has one armed.
905
- const onServiceCrashed = () => {
906
- clearLaunchTimer(appSession);
907
- ctx.diagnostics?.report({
908
- severity: 'error',
909
- code: 'service-host-crashed',
910
- message: `Service host renderer process gone for appSessionId=${appSessionId}`,
911
- appSessionId,
912
- });
913
- pushRuntimeStatus(ctx, appSession, { phase: 'crashed', code: 'service-host-crashed' });
914
- };
915
- appSession.listenerBag.on(serviceWindow.webContents, 'render-process-gone', onServiceCrashed);
916
- pushRuntimeStatus(ctx, appSession, { phase: 'launching' });
917
- startLaunchTimer(state, ctx, appSession);
918
- return {
919
- appSessionId,
920
- bridgeId,
921
- pagePath,
922
- resolvedPagePath,
923
- pageFallbackApplied,
924
- serviceWcId: serviceWindow.webContents.id,
925
- resourceBaseUrl,
926
- manifest,
927
- rootWindowConfig,
928
- };
929
- }
930
- async function handlePageOpen(state, event, opts) {
931
- const ap = state.appSessions.get(opts.appSessionId);
932
- if (!ap)
933
- throw new Error(`[bridge-router] PAGE_OPEN unknown appSession ${opts.appSessionId}`);
934
- // Only the simulator window owning the app can open additional pages.
935
- if (!senderBoundToSession(state, event.sender, ap)) {
936
- throw new Error('[bridge-router] PAGE_OPEN rejected: caller not bound to app session');
937
- }
938
- const pagePath = normalizePagePath(opts.pagePath);
939
- // Defense-in-depth against a page absent from the compiled manifest: the
940
- // nav gate (handleNavActionApi) already blocks normal navigateTo/switchTab
941
- // traffic upstream, but PAGE_OPEN is reachable directly (any IPC caller), so
942
- // this refuses to register a PageSession main can never load resources for.
943
- // Only enforced against a real compiled manifest ('app-config') — a
944
- // 'fallback' manifest has no compiled truth to validate membership against.
945
- if (ap.manifest.source === 'app-config' && !ap.manifest.pages.includes(pagePath)) {
946
- throw new Error(`[bridge-router] PAGE_OPEN rejected: page-not-found "${pagePath}" is not in the compiled manifest`);
947
- }
948
- const bridgeId = opts.bridgeId || newBridgeId();
949
- const windowConfig = resolvePageWindowConfig(ap.appConfig, pagePath);
950
- const isTab = isTabPage(ap.appConfig, pagePath);
951
- const page = {
952
- bridgeId,
953
- appSessionId: opts.appSessionId,
954
- pagePath,
955
- query: opts.query ?? {},
956
- isRoot: false,
957
- isTab,
958
- renderWc: null,
959
- renderLoaded: false,
960
- resourceLoadedSent: false,
961
- renderLoadPending: false,
962
- windowConfig,
963
- };
964
- state.pageSessions.set(bridgeId, page);
965
- ap.pages.set(bridgeId, page);
966
- return { bridgeId, pagePath, windowConfig, isTab };
967
- }
968
- function handlePageClose(state, sender, payload) {
969
- const page = state.pageSessions.get(payload.bridgeId);
970
- if (!page)
971
- return;
972
- if (page.isRoot) {
973
- console.warn('[bridge-router] PAGE_CLOSE refused on root page; use DISPOSE');
974
- return;
975
- }
976
- const ap = state.appSessions.get(page.appSessionId);
977
- if (!ap)
978
- return;
979
- if (!senderBoundToSession(state, sender, ap)) {
980
- console.warn('[bridge-router] PAGE_CLOSE rejected: caller not bound to app session');
981
- return;
982
- }
983
- disposePageSession(state, ap, page);
984
- }
985
- function handlePageLifecycle(state, sender, payload) {
986
- const ap = state.appSessions.get(payload.appSessionId);
987
- if (!ap)
988
- return;
989
- if (!senderBoundToSession(state, sender, ap))
990
- return;
991
- forwardToService(ap, {
992
- type: payload.event,
993
- target: 'service',
994
- body: { bridgeId: payload.bridgeId },
995
- });
996
- }
997
- function handleNavCallback(state, sender, payload) {
998
- const ap = state.appSessions.get(payload.appSessionId);
999
- if (!ap)
1000
- return;
1001
- if (!senderBoundToSession(state, sender, ap))
1002
- return;
1003
- const result = { errMsg: payload.errMsg };
1004
- if (payload.ok) {
1005
- sendCallback(ap, payload.callbacks.success, result);
1006
- }
1007
- else {
1008
- sendCallback(ap, payload.callbacks.fail, result);
1009
- }
1010
- sendCallback(ap, payload.callbacks.complete, result);
1011
- }
1012
- // ── Service-host boot & per-page resource handshake ──────────────────────────
1013
- async function bootServiceHost(state, ap, ctx) {
1014
- // Liveness guard: never boot a session that was already disposed. With pooling,
1015
- // the service window is recycled, so a stale did-finish-load listener from an
1016
- // early-disposed prior owner could otherwise fire here and inject the wrong
1017
- // app's logic.js into the next spawn (the recycled webContents is shared).
1018
- if (state.appSessions.get(ap.appSessionId) !== ap)
1019
- return;
1020
- // The service wc just did-finish-load'd (that's how this got invoked) — flush
1021
- // any diagnostic queued for this session (or the global bucket) into its now
1022
- // resolvable console. Safe to call even when nothing is queued.
1023
- ctx.consoleForwarder?.notifyServiceHostReady?.(ap.appSessionId);
1024
- // Same authoritative moment, exposed as a general subscribable event (see
1025
- // `ServiceHostReadyEvent`'s doc comment) — other main-process consumers
1026
- // (the right-panel DevTools attach) need this exact signal too and must
1027
- // not poll `getServiceWc` on a fixed retry budget for it.
1028
- state.emitServiceHostReady({ appId: ap.appId, appSessionId: ap.appSessionId, serviceWcId: ap.serviceWc.id });
1029
- ap.logicInjected = await injectLogicBundle(ap);
1030
- if (!ap.logicInjected) {
1031
- // The compiled logic.js never executed, so `modDefine` registered nothing.
1032
- // Sending `loadResource` now would run `modRequire('app')` against an empty
1033
- // registry and throw the cryptic `module app not found` — masking the real
1034
- // cause (unreachable resource tree / failed compile / unresolved appId).
1035
- // Skip it and surface one actionable diagnostic instead; the render side
1036
- // gates on the same flag in `routeFromRender`.
1037
- const reason = reportLogicLoadFailure(ap, ctx);
1038
- clearLaunchTimer(ap);
1039
- pushRuntimeStatus(ctx, ap, { phase: 'launch-failed', code: 'logic-bundle-unreachable', reason });
1040
- return;
1041
- }
1042
- // A root page absent from the compiled manifest — most commonly a page the
1043
- // developer deleted, then hot-reloaded to — is not registered in logic.js.
1044
- // BOTH the service runtime AND the render runtime eager-`modRequire(pagePath)`
1045
- // it and throw `module <pagePath> not found`, which aborts the launch and
1046
- // leaves the simulator permanently blank. Gate BOTH sends and surface a
1047
- // WeChat-style diagnostic instead: the shell stays alive (an empty phone frame
1048
- // + a clear Console error) rather than a silent dead blank. Choosing a valid
1049
- // fallback page belongs at the renderer reload source (it owns the pagePath
1050
- // the render guest is spawned with); here we only refuse a load guaranteed to
1051
- // throw, so main and render never disagree about which page is live.
1052
- const rootPage = ap.pages.get(ap.appSessionId);
1053
- const rootMissing = !!rootPage && !pageInManifest(ap, rootPage.pagePath);
1054
- if (rootMissing) {
1055
- reportPageNotFound(ctx, ap.appSessionId, rootPage.pagePath);
1056
- }
1057
- else if (rootPage) {
1058
- // serviceLoaded is flipped only when service responds with
1059
- // `serviceResourceLoaded`; see handleContainerMsg. Setting it here would
1060
- // race a per-page `resourceLoaded` ahead of the service-side handler.
1061
- forwardToService(ap, makeLoadResource(ap, rootPage, 'service'));
1062
- }
1063
- // Flush any render `loadResource` that arrived (renderHostReady) while
1064
- // injection was in-flight — now that the bundle is confirmed present.
1065
- // `sendRenderLoadResource` refuses a page absent from the manifest, so a
1066
- // missing root never reaches the throwing render `modRequire`.
1067
- for (const page of ap.pages.values()) {
1068
- if (!page.renderLoadPending)
1069
- continue;
1070
- page.renderLoadPending = false;
1071
- sendRenderLoadResource(ap, page);
1072
- }
1073
- }
1074
- function sendRenderLoadResource(ap, page) {
1075
- if (!page.renderWc || page.renderWc.isDestroyed())
1076
- return;
1077
- // Single choke point for BOTH the boot flush above and the late
1078
- // `renderHostReady` direct-send in `routeFromRender`: never send a render load
1079
- // for a page absent from the compiled manifest. The render runtime
1080
- // eager-`modRequire`s the pagePath and would throw `module <pagePath> not
1081
- // found`, blanking the simulator (a page the developer deleted, then
1082
- // hot-reloaded to). bootServiceHost surfaces the one-shot diagnostic.
1083
- if (!pageInManifest(ap, page.pagePath))
1084
- return;
1085
- page.renderWc.send(C.TO_RENDER, { msg: makeLoadResource(ap, page, 'render') });
1086
- }
1087
- /** The compiled logic.js URL `injectLogicBundle` fetches (mode-dependent). */
1088
- function logicBundleUrl(ap) {
1089
- return ap.resourceServer
1090
- ? new URL('logic.js', ap.resourceBaseUrl).toString()
1091
- : new URL(`${ap.appId}/${ap.root}/logic.js`, ap.resourceBaseUrl).toString();
1092
- }
1093
- async function injectLogicBundle(ap) {
1094
- // Fetch the compiled service logic over HTTP from the same base the render
1095
- // host reads (`<base><appId>/<root>/logic.js`). The fallback local server
1096
- // serves its root, so `<base>logic.js` resolves there too — both are http.
1097
- const logicUrl = logicBundleUrl(ap);
1098
- try {
1099
- const res = await fetch(logicUrl);
1100
- if (!res.ok)
1101
- throw new Error(`logic.js fetch ${res.status} at ${logicUrl}`);
1102
- // Rewrite the relative `sourceMappingURL` to an absolute dev-server URL
1103
- // BEFORE injecting — `executeJavaScript` gives the script no base URL, so a
1104
- // relative map would 404 and break sourcemapped console frames / Sources.
1105
- const logicContent = rewriteSourceMappingUrl(await res.text(), logicUrl);
1106
- await ap.serviceWc.executeJavaScript(`${logicContent}\n//# sourceURL=${logicUrl}`, true);
1107
- return true;
1108
- }
1109
- catch (error) {
1110
- console.warn('[bridge-router] unable to inject service logic.js:', error);
1111
- return false;
1112
- }
1113
- }
1114
- /**
1115
- * Emit one actionable diagnostic when the compiled logic bundle could not be
1116
- * loaded — the single point where "the app's compiled resource tree is
1117
- * unreachable" is known with certainty. Reported through `ctx.diagnostics`,
1118
- * which mirrors it to the main-process log AND — via `consoleForwarder`'s
1119
- * subscription — injects it into the service host's own console, so it
1120
- * actually surfaces in the embedded DevTools Console panel where the
1121
- * developer would otherwise see only the misleading `module app not found`.
1122
- * `guestConsole.emit` is kept alongside for the automation `App.logAdded`
1123
- * subscriber, which does not consume the diagnostics bus.
1124
- *
1125
- * Returns the short (pre-hint) form of the message so `bootServiceHost` can
1126
- * reuse it verbatim as the `sessionRuntimeStatus` `reason` instead of
1127
- * duplicating the wording.
1128
- */
1129
- function reportLogicLoadFailure(ap, ctx) {
1130
- const hint = ap.appId === 'unknown'
1131
- ? ' appId could not be resolved (it fell back to "unknown") — the mini-program likely failed to compile or its project manifest/app config is missing.'
1132
- : '';
1133
- const shortReason = `[dimina-kit] Failed to load the mini-program logic bundle from ${logicBundleUrl(ap)}.`;
1134
- const message = `${shortReason} `
1135
- + 'The service runtime has no registered modules, so no page can mount.'
1136
- + hint
1137
- + ` Verify the project compiled successfully and that the resource server serves "${ap.appId}/${ap.root}/".`;
1138
- ctx.diagnostics?.report({
1139
- severity: 'error',
1140
- code: 'logic-bundle-unreachable',
1141
- message,
1142
- appSessionId: ap.appSessionId,
1143
- });
1144
- ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
1145
- return shortReason;
1146
- }
1147
- /** Whether `pagePath` still exists in the app's freshly compiled manifest. */
1148
- function pageInManifest(ap, pagePath) {
1149
- // A 'fallback' manifest (app-config.json unreachable) holds only the spawn
1150
- // request — no compiled truth to check membership against. Every mount gate
1151
- // keyed on manifest membership must agree: the nav/PAGE_OPEN front gates
1152
- // already let a fallback manifest through, so this back gate (render
1153
- // loadResource) must too, or a page they admitted silently never loads.
1154
- if (ap.manifest.source !== 'app-config')
1155
- return true;
1156
- return ap.manifest.pages.includes(normalizePagePath(pagePath));
1157
- }
1158
- /**
1159
- * WeChat-devtools-style "page does not exist" diagnostic. Reported through
1160
- * `ctx.diagnostics` (main log + injected into the embedded Console panel via
1161
- * `consoleForwarder`) when a mount targets a pagePath that is not in the
1162
- * compiled manifest — most commonly a page the developer deleted, then
1163
- * hot-reloaded. `guestConsole.emit` is kept alongside for the automation
1164
- * subscriber.
1165
- */
1166
- function reportPageNotFound(ctx, appSessionId, pagePath, fallbackTo) {
1167
- const base = `Page[${pagePath}] not found. May be caused by: 1. Forgetting to add page route in app.json. `
1168
- + '2. Invoking Page() in async task.';
1169
- const message = fallbackTo ? `${base} Falling back to "${fallbackTo}".` : base;
1170
- ctx.diagnostics?.report({
1171
- severity: 'error',
1172
- code: 'page-not-found',
1173
- message,
1174
- appSessionId,
1175
- });
1176
- ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
1177
- }
1178
- function maybeSendResourceLoaded(ap, page) {
1179
- if (page.resourceLoadedSent || !ap.serviceLoaded || !page.renderLoaded)
1180
- return;
1181
- page.resourceLoadedSent = true;
1182
- // Non-root pages (navigateTo / redirectTo / reLaunch / non-cached switchTab)
1183
- // were never sent a SERVICE-side loadResource — only the root gets one at
1184
- // bootServiceHost. Without it the service never `modRequire`s the page module,
1185
- // so `getModuleByPath` misses and `createInstance` no-ops: the page mounts on
1186
- // the render side but its service instance — and thus its AppData / WXML /
1187
- // `Page.getData` — never exist. Mirror the render side, which sends
1188
- // loadResource per page on `renderHostReady`. `loadResource` only registers
1189
- // the module definition (no firstRender), and same-channel FIFO ordering
1190
- // guarantees the module is registered before the `resourceLoaded` below runs
1191
- // `createInstance`. Safety invariants 1-4 reviewed in
1192
- // docs/native-host-abstractions.md.
1193
- if (!page.isRoot) {
1194
- forwardToService(ap, makeLoadResource(ap, page, 'service'));
1195
- }
1196
- forwardToService(ap, {
1197
- type: 'resourceLoaded',
1198
- target: 'service',
1199
- body: {
1200
- bridgeId: page.bridgeId,
1201
- scene: ap.scene,
1202
- pagePath: page.pagePath,
1203
- query: page.query,
1204
- stackId: STACK_ID,
1205
- },
1206
- });
1207
- }
1208
- // ── Message routing ──────────────────────────────────────────────────────────
1209
- function routeFromService(state, ap, defaultPage, msg, ctx) {
1210
- if (msg.type === 'serviceResourceLoaded') {
1211
- handleContainerMsg(ap, defaultPage, msg, ctx, state);
1212
- return;
1213
- }
1214
- if (msg.target === 'render') {
1215
- forwardToRender(ap, msg);
1216
- return;
1217
- }
1218
- if (msg.target === 'container') {
1219
- const page = pageFromMsg(state, ap, msg) ?? defaultPage;
1220
- handleContainerMsg(ap, page, msg, ctx, state);
1221
- }
1222
- }
1223
- function routeFromRender(state, ap, page, msg, ctx) {
1224
- if (msg.type === 'renderHostReady') {
1225
- // Logic injection definitively failed: the render bundle shares the same
1226
- // (unreachable) resource tree, so `loadResource` here would only produce a
1227
- // second cryptic `module <pagePath> not found`. bootServiceHost already
1228
- // surfaced the actionable diagnostic — stay silent.
1229
- if (ap.logicInjected === false)
1230
- return;
1231
- // Injection still in-flight: the render guest fires `renderHostReady` on its
1232
- // own DOMContentLoaded, which routinely beats the async fetch+inject. Hold
1233
- // the render `loadResource` and let `bootServiceHost` flush it once the
1234
- // bundle settles, so a failed bundle never reaches the render side.
1235
- if (ap.logicInjected === null) {
1236
- page.renderLoadPending = true;
1237
- return;
1238
- }
1239
- sendRenderLoadResource(ap, page);
1240
- return;
1241
- }
1242
- if (msg.type === 'renderResourceLoaded') {
1243
- handleContainerMsg(ap, page, msg, ctx, state);
1244
- return;
1245
- }
1246
- if (msg.target === 'service') {
1247
- forwardToService(ap, msg);
1248
- return;
1249
- }
1250
- if (msg.target === 'container') {
1251
- handleContainerMsg(ap, page, msg, ctx, state);
1252
- }
1253
- }
1254
- /**
1255
- * Report 'service-uncaught-error' for a consoleLog(source:'service') body —
1256
- * the service preload's uncaught error/unhandledrejection post. CDP's
1257
- * `Runtime.consoleAPICalled` (services/service-console) never observes these
1258
- * (they never call `console.*`), so this diagnostics report is the only way
1259
- * one reaches the Console panel / main log.
1260
- */
1261
- function reportServiceUncaughtError(ctx, ap, body) {
1262
- const severity = body.level === 'error' ? 'error' : body.level === 'warn' ? 'warn' : 'info';
1263
- const message = Array.isArray(body.args) ? body.args.map(a => String(a)).join(' ') : String(body.args ?? '');
1264
- ctx.diagnostics?.report({ severity, code: 'service-uncaught-error', message, appSessionId: ap.appSessionId });
1265
- }
1266
- function handleContainerMsg(ap, page, msg, ctx, state) {
1267
- switch (msg.type) {
1268
- case 'serviceResourceLoaded':
1269
- ap.serviceLoaded = true;
1270
- for (const p of ap.pages.values())
1271
- maybeSendResourceLoaded(ap, p);
1272
- break;
1273
- case 'renderResourceLoaded': {
1274
- const target = readBridgeId(msg);
1275
- const p = (target && ap.pages.get(target)) || page;
1276
- p.renderLoaded = true;
1277
- maybeSendResourceLoaded(ap, p);
1278
- break;
1279
- }
1280
- case 'domReady':
1281
- if (!ap.simulatorWc.isDestroyed()) {
1282
- ap.simulatorWc.send(E.DOM_READY, { bridgeId: page.bridgeId });
1283
- }
1284
- state.emitRenderEvent({ kind: 'domReady', appId: ap.appId, bridgeId: page.bridgeId });
1285
- markSessionRunning(ctx, ap, page);
1286
- break;
1287
- case 'invokeAPI':
1288
- void handleSimulatorApi(state, ap, page, msg.body, ctx);
1289
- break;
1290
- case 'serviceHostError': {
1291
- // Forward the error to every registered `wx.onError` listener. (The dimina
1292
- // service runtime's App lifecycle is only onLaunch/onShow/onHide, so it
1293
- // does not dispatch `App.onError` — `wx.onError` is the supported path.)
1294
- // The service preload's `deliver` try/catch already reports errors thrown
1295
- // during lifecycle/event dispatch here.
1296
- const errBody = msg.body;
1297
- const errArg = errBody?.message ?? msg.body;
1298
- // Reported through the diagnostics bus (main log + Console-panel
1299
- // injection); `ctx.diagnostics.report` replaces the bare `console.warn`
1300
- // this case used to make directly.
1301
- ctx.diagnostics?.report({
1302
- severity: 'error',
1303
- code: 'service-host-error',
1304
- message: String(errArg),
1305
- appSessionId: ap.appSessionId,
1306
- });
1307
- for (const id of state.appLifecycle.listeners(ap.appSessionId, 'onError')) {
1308
- sendCallback(ap, id, errArg);
1309
- }
1310
- break;
1311
- }
1312
- case 'consoleLog': {
1313
- // Native-host console capture: the render-host / service-host guest preloads
1314
- // monkeypatch console.* and post each entry here (one case handles both —
1315
- // distinguish via msg.body.source). Forward to the console sink (set by the
1316
- // automation server under native-host) so it can rebroadcast as App.logAdded.
1317
- // Guarded + non-throwing: console capture must never break message routing.
1318
- ctx.guestConsole?.emit(msg.body);
1319
- const body = msg.body;
1320
- if (body?.source === 'service')
1321
- reportServiceUncaughtError(ctx, ap, body);
1322
- break;
1323
- }
1324
- case 'storageChanged':
1325
- // Native-host SYNC storage liveness: the service-host's `setStorageSync`/etc.
1326
- // write `localStorage` directly (no main round-trip), so they post the change
1327
- // here for the Storage panel to stay live. Trust `ap.appId` (sender-resolved),
1328
- // not any appId in the body. Guarded + non-throwing.
1329
- ctx.onServiceStorageChanged?.(ap.appId, msg.body);
1330
- break;
1331
- case 'wxmlChanged':
1332
- // Native-host WXML liveness: the render-guest MutationObserver posts this when
1333
- // the active page's DOM mutated in place (setData). Surface it as a render
1334
- // event so the WXML panel service re-pulls + pushes — same pipeline as
1335
- // domReady/activePage. Trust `page.bridgeId` (sender-resolved), not the body.
1336
- state.emitRenderEvent({ kind: 'domMutated', appId: ap.appId, bridgeId: page.bridgeId });
1337
- break;
1338
- default:
1339
- break;
1340
- }
1341
- }
1342
- // ── Simulator-window-resident APIs ──────────────────────────────────────────
1343
- const NAV_BAR_API_NAMES = new Set([
1344
- 'setNavigationBarTitle',
1345
- 'setNavigationBarColor',
1346
- 'showNavigationBarLoading',
1347
- 'hideNavigationBarLoading',
1348
- 'hideHomeButton',
1349
- ]);
1350
- const NAV_ACTION_NAMES = new Set([
1351
- 'navigateTo',
1352
- 'navigateBack',
1353
- 'redirectTo',
1354
- 'reLaunch',
1355
- 'switchTab',
1356
- ]);
1357
- const TAB_ACTION_NAMES = new Set([
1358
- 'setTabBarStyle',
1359
- 'setTabBarItem',
1360
- 'showTabBar',
1361
- 'hideTabBar',
1362
- 'setTabBarBadge',
1363
- 'removeTabBarBadge',
1364
- 'showTabBarRedDot',
1365
- 'hideTabBarRedDot',
1366
- ]);
1367
- /**
1368
- * Drive app foreground/background from the main window's visibility. Minimizing
1369
- * or hiding the window backgrounds the mini-program (App.onHide + wx.onAppHide);
1370
- * restoring or showing it foregrounds it (App.onShow + wx.onAppShow). Focus/blur
1371
- * is deliberately NOT used — clicking a DevTools panel would falsely fire it.
1372
- * The initial App.onShow fires once at spawn (service `invokeSomeLifecycle`), so
1373
- * the driver only handles subsequent transitions.
1374
- */
1375
- function installAppLifecycleDriver(ctx, state) {
1376
- const win = ctx.windows?.mainWindow;
1377
- // Guard against a headless/stub mainWindow (unit tests) that isn't a real
1378
- // event-emitting BrowserWindow.
1379
- if (!win || typeof win.on !== 'function')
1380
- return;
1381
- const emit = (serviceEvent, listenerEvent) => {
1382
- for (const ap of state.appSessions.values()) {
1383
- // App.onShow / onHide: the service runtime already listens for these.
1384
- forwardToService(ap, { type: serviceEvent, target: 'service', body: {} });
1385
- // wx.onAppShow / onAppHide imperative listeners.
1386
- for (const id of state.appLifecycle.listeners(ap.appSessionId, listenerEvent)) {
1387
- sendCallback(ap, id, {});
1388
- }
1389
- }
1390
- };
1391
- const onHide = () => emit('appHide', 'onAppHide');
1392
- const onShow = () => emit('appShow', 'onAppShow');
1393
- win.on('hide', onHide);
1394
- win.on('minimize', onHide);
1395
- win.on('show', onShow);
1396
- win.on('restore', onShow);
1397
- ctx.registry.add(() => {
1398
- win.off('hide', onHide);
1399
- win.off('minimize', onHide);
1400
- win.off('show', onShow);
1401
- win.off('restore', onShow);
1402
- });
1403
- }
1404
- // wx app-event API name → the lifecycle event whose listeners it (de)registers.
1405
- const APP_LIFECYCLE_REGISTER = {
1406
- onAppShow: 'onAppShow',
1407
- onAppHide: 'onAppHide',
1408
- onError: 'onError',
1409
- };
1410
- const APP_LIFECYCLE_UNREGISTER = {
1411
- offAppShow: 'onAppShow',
1412
- offAppHide: 'onAppHide',
1413
- offError: 'onError',
1414
- };
1415
- function handleNavBarApi(ap, page, name, params) {
1416
- if (!ap.simulatorWc.isDestroyed()) {
1417
- ap.simulatorWc.send(E.NAV_BAR, {
1418
- bridgeId: page.bridgeId,
1419
- name,
1420
- params,
1421
- });
1422
- }
1423
- const successResult = { errMsg: `${name}:ok` };
1424
- sendCallback(ap, params.success, successResult);
1425
- sendCallback(ap, params.complete, successResult);
1426
- }
1427
- // Fails a nav/tab action's service-side callback through the same success/
1428
- // complete dispatch every other API branch uses — the one mechanism for
1429
- // answering a call that never reaches (or never should reach) the simulator.
1430
- function failActionCallback(ap, params, errMsg) {
1431
- const fail = { errMsg };
1432
- sendCallback(ap, params.fail, fail);
1433
- sendCallback(ap, params.complete, fail);
1434
- }
1435
- // Shared by handleNavActionApi/handleTabActionApi: both forward a same-shaped
1436
- // payload to the simulator window, differing only in the event name and the
1437
- // payload's static `name` union. Simulator-destroyed fails the callback
1438
- // directly since there is no ack path to fail through otherwise.
1439
- function sendActionOrFail(ap, eventName, payload, name, params) {
1440
- if (!ap.simulatorWc.isDestroyed()) {
1441
- ap.simulatorWc.send(eventName, payload);
1442
- return;
1443
- }
1444
- failActionCallback(ap, params, `${name}:fail simulator window destroyed`);
1445
- }
1446
- /** The bare page path a nav/tab action targets (query-stripped, normalized), or undefined for an action carrying none (navigateBack). */
1447
- function extractNavTargetPagePath(params) {
1448
- const raw = params.url;
1449
- if (typeof raw !== 'string' || !raw)
1450
- return undefined;
1451
- return normalizePagePath(raw.split('?')[0] ?? raw);
1452
- }
1453
- /**
1454
- * Whether a nav/tab action's target page is mountable — checked ONLY against
1455
- * a real compiled manifest (`source === 'app-config'`); a `'fallback'`
1456
- * manifest can't tell mountable from not, so it lets everything through.
1457
- */
1458
- function checkNavTarget(ap, name, targetPagePath) {
1459
- if (ap.manifest.source !== 'app-config')
1460
- return { ok: true };
1461
- if (!ap.manifest.pages.includes(targetPagePath)) {
1462
- return { ok: false, errMsg: `${name}:fail page "${targetPagePath}" is not found`, reportNotFound: true };
1463
- }
1464
- if (name === 'switchTab') {
1465
- const inTabBar = ap.manifest.tabBar?.list.some(item => normalizePagePath(item.pagePath) === targetPagePath) ?? false;
1466
- if (!inTabBar)
1467
- return { ok: false, errMsg: 'switchTab:fail can not switch to no-tabBar page' };
1468
- }
1469
- return { ok: true };
1470
- }
1471
- function handleNavActionApi(ap, ctx, page, name, params) {
1472
- const targetPagePath = extractNavTargetPagePath(params);
1473
- if (targetPagePath !== undefined) {
1474
- const verdict = checkNavTarget(ap, name, targetPagePath);
1475
- if (!verdict.ok) {
1476
- if (verdict.reportNotFound)
1477
- reportPageNotFound(ctx, ap.appSessionId, targetPagePath);
1478
- failActionCallback(ap, params, verdict.errMsg);
1479
- return;
1480
- }
1481
- }
1482
- const payload = {
1483
- appSessionId: ap.appSessionId,
1484
- bridgeId: page.bridgeId,
1485
- name: name,
1486
- params,
1487
- callbacks: extractCallbacks(params),
1488
- };
1489
- sendActionOrFail(ap, E.NAV_ACTION, payload, name, params);
1490
- }
1491
- function handleTabActionApi(ap, page, name, params) {
1492
- const payload = {
1493
- appSessionId: ap.appSessionId,
1494
- bridgeId: page.bridgeId,
1495
- name: name,
1496
- params,
1497
- callbacks: extractCallbacks(params),
1498
- };
1499
- sendActionOrFail(ap, E.TAB_ACTION, payload, name, params);
1500
- }
1501
- // App-level lifecycle listeners (wx.onAppShow / onAppHide / onError + off*).
1502
- // The service encodes the listener as a keep callback id in `params.success`;
1503
- // we store it and re-fire on main-window foreground/background (the driver in
1504
- // installBridgeRouter) and on serviceHostError. `off*` clears the event's
1505
- // listeners for the session. Returns true when `name` matched a lifecycle
1506
- // register/unregister call (fully handled), false to fall through.
1507
- function handleAppLifecycleToggle(state, ap, name, params) {
1508
- const lifecycleRegister = APP_LIFECYCLE_REGISTER[name];
1509
- if (lifecycleRegister) {
1510
- state.appLifecycle.register(ap.appSessionId, lifecycleRegister, params.success);
1511
- return true;
1512
- }
1513
- const lifecycleUnregister = APP_LIFECYCLE_UNREGISTER[name];
1514
- if (lifecycleUnregister) {
1515
- // off(cb) carries the same evtId as the original on(cb) → removes just that
1516
- // listener; off() with no callback carries no id → clears the whole event.
1517
- state.appLifecycle.unregister(ap.appSessionId, lifecycleUnregister, params.success);
1518
- return true;
1519
- }
1520
- return false;
1521
- }
1522
- // pageScrollTo acts on the page's render guest (scroll its document), which
1523
- // only the main process can reach — run the scroll script in the invoking
1524
- // page's render webContents rather than forwarding to the simulator.
1525
- function handlePageScrollApi(ap, page, params) {
1526
- const renderWc = page.renderWc;
1527
- if (renderWc && !renderWc.isDestroyed()) {
1528
- void renderWc.executeJavaScript(buildPageScrollScript(params)).catch(() => { });
1529
- }
1530
- const successResult = { errMsg: 'pageScrollTo:ok' };
1531
- sendCallback(ap, params.success, successResult);
1532
- sendCallback(ap, params.complete, successResult);
1533
- }
1534
- // Shared try/invoke/callback pattern used by the storageApi and
1535
- // ctx.simulatorApis branches: relay the resolved value through
1536
- // success+complete, or the thrown error through fail+complete with a
1537
- // namespaced errMsg.
1538
- async function invokeSimulatorApiAndCallback(ap, name, params, invoke) {
1539
- try {
1540
- const result = await invoke();
1541
- sendCallback(ap, params.success, result);
1542
- sendCallback(ap, params.complete, result);
1543
- }
1544
- catch (error) {
1545
- const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
1546
- sendCallback(ap, params.fail, failResult);
1547
- sendCallback(ap, params.complete, failResult);
1548
- }
1549
- }
1550
- async function handleSimulatorApi(state, ap, page, body, ctx) {
1551
- const name = String(body.name ?? '');
1552
- const params = normalizeParams(body.params);
1553
- if (NAV_BAR_API_NAMES.has(name)) {
1554
- handleNavBarApi(ap, page, name, params);
1555
- return;
1556
- }
1557
- if (NAV_ACTION_NAMES.has(name)) {
1558
- handleNavActionApi(ap, ctx, page, name, params);
1559
- return;
1560
- }
1561
- if (TAB_ACTION_NAMES.has(name)) {
1562
- handleTabActionApi(ap, page, name, params);
1563
- return;
1564
- }
1565
- if (handleAppLifecycleToggle(state, ap, name, params))
1566
- return;
1567
- if (name === 'pageScrollTo') {
1568
- handlePageScrollApi(ap, page, params);
1569
- return;
1570
- }
1571
- // Native-host storage unification: route async wx.setStorage/getStorage/etc.
1572
- // to the service-host window's file:// store (the same store the *Sync APIs +
1573
- // the Storage panel use), instead of forwarding to the simulator guest's
1574
- // http:// origin. Without this, async writes and sync writes diverge across
1575
- // two origins even for the running mini-app.
1576
- if (ctx.storageApi && STORAGE_API_NAMES.has(name)) {
1577
- const storageApi = ctx.storageApi;
1578
- await invokeSimulatorApiAndCallback(ap, name, params, () => storageApi.invoke(ap.appId, name, params));
1579
- return;
1580
- }
1581
- // Main-process registry (downstream-registered host APIs via
1582
- // instance.registerSimulatorApi). If the name is not registered here, fall
1583
- // through to the simulator-window forwarding branch — the simulator-resident
1584
- // MiniApp owns the DOM-touching defaults (wx.getSystemInfo, chooseImage,
1585
- // chooseMedia, fs.*, …) and the bridge-router can't run those itself.
1586
- if (ctx.simulatorApis.has(name)) {
1587
- await invokeSimulatorApiAndCallback(ap, name, params, () => ctx.simulatorApis.invoke(name, params));
1588
- return;
1589
- }
1590
- forwardApiCallToSimulator(state, ap, page, name, params);
1591
- }
1592
- function forwardApiCallToSimulator(state, ap, page, name, params) {
1593
- const callbacks = extractCallbacks(params);
1594
- if (ap.simulatorWc.isDestroyed()) {
1595
- const fail = { errMsg: `${name}:fail simulator window destroyed` };
1596
- sendCallback(ap, callbacks.fail, fail);
1597
- sendCallback(ap, callbacks.complete, fail);
1598
- return;
1599
- }
1600
- const requestId = newRequestId();
1601
- // Persistent subscriptions (`keep: true`, e.g. audioListen) bind their event
1602
- // bridge synchronously but only emit their FIRST response when the first
1603
- // event fires (which can be well past the no-handler window). Arming the
1604
- // one-shot timeout would tear the subscription down before it ever delivers,
1605
- // so keep calls run without a timeout and are reaped on page/app teardown.
1606
- // Recognise persistent subscriptions BY NAME (`audioListen`) too: the service
1607
- // host strips the original `keep: true` before forwarding, so `params.keep` is
1608
- // gone by the time we route the call. See shared/simulator-api-metadata.ts.
1609
- //
1610
- // The watchdog window is per-call (apiCallWatchdogMs): network-budget APIs
1611
- // (request/downloadFile/uploadFile) get their wx timeout budget + grace so a
1612
- // slow-but-alive HTTP call is never torn down before its handler's own
1613
- // deadline; everything else keeps the flat 5s missing-handler window.
1614
- const keep = params.keep === true || isPersistentSimulatorApi(name);
1615
- const timer = keep
1616
- ? undefined
1617
- : setTimeout(() => {
1618
- const pending = state.pendingApiCalls.get(requestId);
1619
- if (!pending)
1620
- return;
1621
- state.pendingApiCalls.delete(requestId);
1622
- const target = state.appSessions.get(pending.appSessionId);
1623
- if (!target)
1624
- return;
1625
- const fail = { errMsg: `${pending.name}:fail no handler (timeout)` };
1626
- sendCallback(target, pending.callbacks.fail, fail);
1627
- sendCallback(target, pending.callbacks.complete, fail);
1628
- }, apiCallWatchdogMs(name, params));
1629
- state.pendingApiCalls.set(requestId, {
1630
- appSessionId: ap.appSessionId,
1631
- callbacks,
1632
- name,
1633
- timer,
1634
- });
1635
- // Strip the callback ids before forwarding: callback ids are
1636
- // service-host-side identifiers and the simulator runs its own
1637
- // capture-based callback shim. Forwarding them would be both noisy
1638
- // and a potential id-namespace collision.
1639
- const forwardedParams = {};
1640
- for (const [k, v] of Object.entries(params)) {
1641
- if (k === 'success' || k === 'fail' || k === 'complete')
1642
- continue;
1643
- forwardedParams[k] = v;
1644
- }
1645
- const payload = {
1646
- appSessionId: ap.appSessionId,
1647
- bridgeId: page.bridgeId,
1648
- requestId,
1649
- name,
1650
- params: forwardedParams,
1651
- // Echoed back unchanged in the API_RESPONSE so main can ack against the
1652
- // original service-side callback ids without keeping them in pending.
1653
- callbacks,
1654
- };
1655
- ap.simulatorWc.send(E.API_CALL, payload);
1656
- }
1657
- function handleApiResponse(state, sender, payload) {
1658
- const pending = state.pendingApiCalls.get(payload.requestId);
1659
- if (!pending)
1660
- return;
1661
- const ap = state.appSessions.get(pending.appSessionId);
1662
- if (!ap) {
1663
- // Owning app is gone — drop the stale entry regardless of keep.
1664
- state.pendingApiCalls.delete(payload.requestId);
1665
- clearTimeout(pending.timer);
1666
- return;
1667
- }
1668
- // Only a simulator window bound to the OWNING session may respond. Membership
1669
- // (senderBoundToSession), not sender-resolution equality: during a soft-reload
1670
- // overlap the shared simulator wc still answers the outgoing session's
1671
- // in-flight calls, which a latest-wins comparison would drop. Validate BEFORE
1672
- // mutating pending state so a spoofed/foreign response can't tear down a
1673
- // live subscription.
1674
- if (!senderBoundToSession(state, sender, ap)) {
1675
- console.warn('[bridge-router] API_RESPONSE rejected: sender not bound to app session');
1676
- return;
1677
- }
1678
- // Persistent-subscription fire (keep: true, e.g. audioListen). Re-fire the
1679
- // service-side success callback on EVERY response without tearing the call
1680
- // down: do not delete pending, do not fire `complete`. The 5s no-handler
1681
- // timeout is no longer relevant once the subscription has produced a fire,
1682
- // so clear it but keep the entry alive until page/app teardown (which
1683
- // drains pendingApiCalls in the dispose path).
1684
- // `payload.keep` OR a by-name persistent API: be robust even if the verdict
1685
- // on the wire didn't echo `keep` (the service host strips it), so the router
1686
- // never tears a known subscription (`audioListen`) down as a one-shot.
1687
- if ((payload.keep || isPersistentSimulatorApi(pending.name)) && payload.ok) {
1688
- clearTimeout(pending.timer);
1689
- pending.timer = undefined;
1690
- sendCallback(ap, pending.callbacks.success, payload.result);
1691
- return;
1692
- }
1693
- // One-shot path (default): fire the verdict's callbacks once and clean up.
1694
- state.pendingApiCalls.delete(payload.requestId);
1695
- clearTimeout(pending.timer);
1696
- if (payload.ok) {
1697
- sendCallback(ap, pending.callbacks.success, payload.result);
1698
- sendCallback(ap, pending.callbacks.complete, payload.result ?? { errMsg: `${pending.name}:ok` });
1699
- }
1700
- else {
1701
- // The router is a transport, not an author: the simulator-side fail result
1702
- // (errno etc.) must reach the service callbacks intact, so spread it
1703
- // through instead of rebuilding a bare { errMsg }. errMsg is guaranteed
1704
- // non-empty — `||` (not `??`) so an empty string (e.g. an HTTP/2 response
1705
- // whose statusText is always '') falls through payload.errMsg →
1706
- // result.errMsg → the `${name}:fail` default instead of surfacing blank.
1707
- const result = payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result)
1708
- ? payload.result
1709
- : undefined;
1710
- const resultErrMsg = typeof result?.errMsg === 'string' ? result.errMsg : undefined;
1711
- const fail = {
1712
- ...result,
1713
- errMsg: payload.errMsg || resultErrMsg || `${pending.name}:fail`,
1714
- };
1715
- sendCallback(ap, pending.callbacks.fail, fail);
1716
- sendCallback(ap, pending.callbacks.complete, fail);
1717
- }
1718
- }
1719
- function newRequestId() {
1720
- return `apicall_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1721
- }
1722
- function extractCallbacks(params) {
1723
- return { success: params.success, fail: params.fail, complete: params.complete };
1724
- }
1725
- function normalizeParams(params) {
1726
- return params && typeof params === 'object' && !Array.isArray(params)
1727
- ? params
1728
- : { value: params };
1729
- }
1730
- function sendCallback(ap, id, args) {
1731
- if (id === undefined || id === null)
1732
- return;
1733
- forwardToService(ap, {
1734
- type: 'triggerCallback',
1735
- target: 'service',
1736
- body: { id, args },
1737
- });
1738
- }
1739
- // ── Resource helpers ────────────────────────────────────────────────────────
1740
- function makeLoadResource(ap, page, target) {
1741
- return {
1742
- type: 'loadResource',
1743
- target,
1744
- body: {
1745
- appId: ap.appId,
1746
- bridgeId: page.bridgeId,
1747
- pagePath: page.pagePath,
1748
- root: ap.root,
1749
- baseUrl: ap.resourceBaseUrl,
1750
- resourceBaseUrl: ap.resourceBaseUrl,
1751
- // dimina's service runtime reads hostEnv as `{ systemInfo, menuRect }`
1752
- // (core/host-env.js init → getSystemInfo/getMenuRect; invokeAPI resolves
1753
- // getSystemInfoSync/getWindowInfo/getDeviceInfo from hostEnv.systemInfo).
1754
- // The snapshot must be nested under `systemInfo`: a FLAT HostEnvSnapshot
1755
- // leaves `systemInfo` null → `wx.getSystemInfoSync()` returns null and
1756
- // pages reading `.screenWidth` throw. (render does NOT read hostEnv, so
1757
- // this is service-only; the devtools sync-api-patch reads the separate
1758
- // __diminaSpawnContext.hostEnvSnapshot and is unaffected.)
1759
- // menuRect stays null — getMenuButtonBoundingClientRect is served by the
1760
- // sync-api-patch / DeviceShell capsule, not this path.
1761
- hostEnv: { systemInfo: ap.hostEnv, menuRect: null },
1762
- },
1763
- };
1764
- }
1765
- function forwardToService(ap, msg) {
1766
- if (!ap.serviceWc.isDestroyed()) {
1767
- ap.serviceWc.send(C.TO_SERVICE, { msg });
1768
- }
1769
- }
1770
- function forwardToRender(ap, msg, targetBridgeId) {
1771
- const renderBridgeId = targetBridgeId || readBridgeId(msg);
1772
- if (!renderBridgeId) {
1773
- throw new Error('[bridge-router] cannot route to render: missing bridgeId in body and no explicit target');
1774
- }
1775
- const page = ap.pages.get(renderBridgeId);
1776
- if (!page)
1777
- return;
1778
- const renderWc = page.renderWc;
1779
- if (renderWc && !renderWc.isDestroyed()) {
1780
- renderWc.send(C.TO_RENDER, { msg });
1781
- }
1782
- }
1783
- function ensureRenderBound(state, sender, bridgeId) {
1784
- const page = state.pageSessions.get(bridgeId);
1785
- if (!page)
1786
- return undefined;
1787
- if (page.renderWc !== sender) {
1788
- if (page.renderWc && page.renderWc !== sender && !page.renderWc.isDestroyed()) {
1789
- console.warn(`[bridge-router] page ${bridgeId} render webview swap (wc ${page.renderWc.id} → ${sender.id})`);
1790
- }
1791
- page.renderWc = sender;
1792
- state.wcIdToBridgeId.set(sender.id, bridgeId);
1793
- // Consolidate the render-guest bookkeeping teardown onto the connection
1794
- // layer (see foundation.md): the render guest is its own webContents =
1795
- // its own connection; own() ties this cleanup to its lifetime so it
1796
- // fires on destroy, replacing the bespoke `once('destroyed')`. acquire() is
1797
- // idempotent — re-binding the same sender re-uses its connection.
1798
- state.connections.acquire(sender).own(() => {
1799
- const p = state.pageSessions.get(bridgeId);
1800
- if (p && p.renderWc === sender)
1801
- p.renderWc = null;
1802
- if (state.wcIdToBridgeId.get(sender.id) === bridgeId)
1803
- state.wcIdToBridgeId.delete(sender.id);
1804
- });
1805
- }
1806
- return page;
1807
- }
1808
- function pageFromMsg(state, ap, msg) {
1809
- const target = readBridgeId(msg);
1810
- if (!target)
1811
- return undefined;
1812
- const page = ap.pages.get(target);
1813
- return page;
1814
- }
1815
- function appByWc(state, wc) {
1816
- if (wc.isDestroyed())
1817
- return undefined;
1818
- const appSessionId = state.serviceWcIdToAppSessionId.get(wc.id);
1819
- if (appSessionId)
1820
- return state.appSessions.get(appSessionId);
1821
- // A simulator wc hosts several sessions during a soft reload; a message that
1822
- // doesn't name its session belongs to the LATEST still-alive spawn (the Set
1823
- // preserves spawn order).
1824
- const hosted = state.simulatorWcIdToAppSessionIds.get(wc.id);
1825
- if (hosted) {
1826
- let latest;
1827
- for (const id of hosted)
1828
- latest = state.appSessions.get(id) ?? latest;
1829
- if (latest)
1830
- return latest;
1831
- }
1832
- const bridgeId = state.wcIdToBridgeId.get(wc.id);
1833
- if (bridgeId) {
1834
- const page = state.pageSessions.get(bridgeId);
1835
- if (page)
1836
- return state.appSessions.get(page.appSessionId);
1837
- }
1838
- return undefined;
1839
- }
1840
- /**
1841
- * Whether `sender` is authorized to control app session `ap`. A simulator WCV
1842
- * is authorized for EVERY session it hosts (`simulatorWcIdToAppSessionIds` is
1843
- * a membership set, so an older session's own control traffic stays valid
1844
- * while a newer spawn boots on the same wc); any other sender (service host,
1845
- * render guest) resolves one-to-one through `appByWc`.
1846
- */
1847
- function senderBoundToSession(state, sender, ap) {
1848
- if (sender.isDestroyed())
1849
- return false;
1850
- const hosted = state.simulatorWcIdToAppSessionIds.get(sender.id);
1851
- if (hosted)
1852
- return hosted.has(ap.appSessionId);
1853
- return appByWc(state, sender)?.appSessionId === ap.appSessionId;
1854
- }
1855
- function resolveAppByBridgeId(state, bridgeId) {
1856
- if (state.appSessions.has(bridgeId))
1857
- return state.appSessions.get(bridgeId);
1858
- const page = state.pageSessions.get(bridgeId);
1859
- if (page)
1860
- return state.appSessions.get(page.appSessionId);
1861
- return undefined;
1862
- }
1863
- function bindWc(map, wc, id) {
1864
- if (wc.isDestroyed())
1865
- return;
1866
- map.set(wc.id, id);
1867
- }
1868
- /** Add a session to a simulator wc's hosted set (created on first bind). */
1869
- function bindSimulatorWc(map, wc, id) {
1870
- if (wc.isDestroyed())
1871
- return;
1872
- let hosted = map.get(wc.id);
1873
- if (!hosted) {
1874
- hosted = new Set();
1875
- map.set(wc.id, hosted);
1876
- }
1877
- hosted.add(id);
1878
- }
1879
- function readBridgeId(msg) {
1880
- return typeof msg.body?.bridgeId === 'string' ? msg.body.bridgeId : undefined;
1881
- }
1882
- function newBridgeId() {
1883
- return `bridge_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1884
- }
1885
- function resolveSimulatorWebContents(ctx, simulatorWcId, sender) {
1886
- if (!simulatorWcId)
1887
- return sender ?? ctx.windows.mainWindow.webContents;
1888
- return webContents.fromId(simulatorWcId) ?? ctx.windows.mainWindow.webContents;
1889
- }
1890
- // ── Dispose ─────────────────────────────────────────────────────────────────
1891
- function disposePageSession(state, ap, page) {
1892
- if (page.renderWc && !page.renderWc.isDestroyed()) {
1893
- state.wcIdToBridgeId.delete(page.renderWc.id);
1894
- }
1895
- ap.pages.delete(page.bridgeId);
1896
- state.pageSessions.delete(page.bridgeId);
1897
- }
1898
- // Drain any pending API calls owned by this app session. One-shot calls
1899
- // normally self-clean on response/timeout, but persistent (`keep: true`)
1900
- // subscriptions (e.g. audioListen) live with their timer cleared until
1901
- // teardown — without this they would leak in pendingApiCalls forever.
1902
- function drainPendingApiCallsForSession(state, appSessionId) {
1903
- for (const [requestId, pending] of state.pendingApiCalls) {
1904
- if (pending.appSessionId !== appSessionId)
1905
- continue;
1906
- clearTimeout(pending.timer);
1907
- state.pendingApiCalls.delete(requestId);
1908
- }
1909
- }
1910
- // Closes every page's render guest and drops its wc/pageSessions bindings.
1911
- // Caller clears `ap.pages` itself right after — this only tears each page
1912
- // down, it doesn't touch the map.
1913
- function closeSessionPages(state, ap) {
1914
- for (const page of ap.pages.values()) {
1915
- if (page.renderWc && !page.renderWc.isDestroyed()) {
1916
- state.wcIdToBridgeId.delete(page.renderWc.id);
1917
- // The render-host <webview> guest is otherwise reclaimed only when its
1918
- // host simulator WCV is destroyed, and that cascade is async. Close it
1919
- // here so a project-close / respawn tears the guest down deterministically
1920
- // rather than leaving the outgoing project's guest alive to be
1921
- // screenshotted or re-resolved by the next project. Idempotent with the
1922
- // cascade (guarded on isDestroyed).
1923
- try {
1924
- page.renderWc.close();
1925
- }
1926
- catch { /* guest already gone */ }
1927
- }
1928
- state.pageSessions.delete(page.bridgeId);
1929
- }
1930
- }
1931
- // Three-way release of the session's service-host window: pool release (soft
1932
- // reuse), pool reclaim of an externally-closed window, or a plain close for
1933
- // the unpooled path.
1934
- async function releaseServiceWindow(state, ap, opts) {
1935
- if (ap.poolEntryId !== null && state.pool && !opts.serviceAlreadyClosed) {
1936
- // Soft reuse (see foundation.md): the pooled service-host webContents keeps
1937
- // its wc.id but is about to run a NEW app session. Reset its Connection
1938
- // BEFORE returning it to the pool — dispose this session's owned segment
1939
- // (the serviceWc binding cleanup) and swap a fresh one — so an old in-flight
1940
- // response can't bleed into the next session and the next spawn `acquire`s a
1941
- // clean segment. The connection object stays alive (it's reused), so this is
1942
- // reset (not close); a real destroy still closes it via its 'destroyed' hook.
1943
- state.connections.reset(ap.serviceWc.id);
1944
- // Return the window to the pool instead of closing it. Detach the boot
1945
- // listener first (the 'closed' hook already came off with the listener-bag
1946
- // dispose above) so a stale did-finish-load can't boot this disposed
1947
- // session into the next spawn's recycled window.
1948
- if (ap.onServiceBoot && !ap.serviceWindow.isDestroyed()) {
1949
- ap.serviceWindow.webContents.removeListener('did-finish-load', ap.onServiceBoot);
1950
- }
1951
- await state.pool.release(ap.poolEntryId, ap.serviceWindow).catch((error) => {
1952
- console.warn('[bridge-router] pool release failed:', error);
1953
- });
1954
- }
1955
- else if (ap.poolEntryId !== null && state.pool && opts.serviceAlreadyClosed) {
1956
- // The pooled service window already closed/crashed externally (its 'closed'
1957
- // fired → onServiceClosed → here). `release` is skipped on this path, and the
1958
- // pool only auto-reclaims on render-process-gone — so without this the in-use
1959
- // entry leaks in the pool forever, permanently shrinking capacity. Reclaim
1960
- // the slot explicitly (the window is already gone; releaseDestroyed won't
1961
- // touch it).
1962
- state.pool.releaseDestroyed(ap.poolEntryId);
1963
- }
1964
- else if (!opts.serviceAlreadyClosed && !ap.serviceWindow.isDestroyed()) {
1965
- ap.serviceWindow.close();
1966
- }
1967
- }
1968
- function unbindSessionFromSharedMaps(state, ap, appSessionId) {
1969
- // Value-checked unbind for the service wc: this delete runs after the
1970
- // pool-release await above, so on the pool path the next spawn may have
1971
- // already re-acquired the SAME window and rebound its wc id — only remove
1972
- // an entry that still names THIS session.
1973
- if (state.serviceWcIdToAppSessionId.get(ap.serviceWc.id) === appSessionId) {
1974
- state.serviceWcIdToAppSessionId.delete(ap.serviceWc.id);
1975
- }
1976
- // The simulator wc's hosted set sheds only this session; sessions that are
1977
- // still live on the shared wc (the soft-reload survivor) keep their
1978
- // membership. The map entry goes away with the last session.
1979
- const hosted = state.simulatorWcIdToAppSessionIds.get(ap.simulatorWc.id);
1980
- if (hosted) {
1981
- hosted.delete(appSessionId);
1982
- if (hosted.size === 0)
1983
- state.simulatorWcIdToAppSessionIds.delete(ap.simulatorWc.id);
1984
- }
1985
- }
1986
- async function disposeAppSession(state, appSessionId, opts = {}) {
1987
- const ap = state.appSessions.get(appSessionId);
1988
- if (!ap)
1989
- return;
1990
- state.appSessions.delete(appSessionId);
1991
- // Prevent a leaked/stale watchdog: without this, a session disposed while
1992
- // still 'launching' (e.g. project close mid-launch) would fire its timer
1993
- // `LAUNCH_TIMEOUT_MS` later against an already-torn-down session.
1994
- clearLaunchTimer(ap);
1995
- // Remove this session's shutdown-fallback registry entry so the registry does
1996
- // not accumulate one stale closure per respawn. Ordered AFTER the delete
1997
- // above: dispose() runs the wrapped fn (disposeAppSession again), which
1998
- // early-returns now that the session is gone — no recursion, no double-run.
1999
- // (At whole-app shutdown the registry marks the entry released before calling
2000
- // the fn, so this dispose() is a no-op then.)
2001
- const registryHandle = ap.registryHandle;
2002
- ap.registryHandle = null;
2003
- void registryHandle?.dispose();
2004
- state.appLifecycle.dispose(appSessionId);
2005
- // Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the
2006
- // page teardown below progressively empties (and finally clears).
2007
- state.evictAppDataBridges(ap);
2008
- drainPendingApiCallsForSession(state, appSessionId);
2009
- closeSessionPages(state, ap);
2010
- ap.pages.clear();
2011
- // Detach every session-scoped hook on emitters that outlive this session —
2012
- // the shared simulator WCV's 'destroyed' and the service window's 'closed' —
2013
- // BEFORE the window is released/closed below: a pool-recycled window must
2014
- // not re-trigger this session's teardown from a stale 'closed', and the
2015
- // simulator wc must not accumulate one dead hook per soft reload until the
2016
- // MaxListeners warning fires. Idempotent with teardown being triggered BY
2017
- // one of these hooks (removing a fired once() is a no-op).
2018
- ap.listenerBag.dispose();
2019
- await releaseServiceWindow(state, ap, opts);
2020
- unbindSessionFromSharedMaps(state, ap, appSessionId);
2021
- // Only the local fallback server needs closing; the dev-server base is owned
2022
- // by the workspace session, not this app session.
2023
- if (ap.resourceServer) {
2024
- await ap.resourceServer.close().catch((error) => {
2025
- console.warn('[bridge-router] resource server close failed:', error);
2026
- });
2027
- }
2028
- }
2029
- // ── App-config / manifest parsing ───────────────────────────────────────────
2030
- /**
2031
- * Fetch+parse `app-config.json`. Pure (no `ctx`, no diagnostics dependency) —
2032
- * a non-2xx response or a parse/network failure reports through `onUnreachable`
2033
- * (the caller routes it into `ctx.diagnostics`) and returns `{}` either way, so
2034
- * the spawn flow always continues with a manifest that just falls back to a
2035
- * single-page/no-tabBar shape (`buildAppManifest`'s fallback).
2036
- */
2037
- async function loadAppConfig(resourceBase, onUnreachable) {
2038
- // `resourceBase` is the dir/URL that directly contains `app-config.json`:
2039
- // the dev server's `<base><appId>/<root>/` (http) or the local fallback
2040
- // server root (also http). Both are HTTP, so a single fetch path covers them.
2041
- const cfgUrl = new URL('app-config.json', resourceBase.endsWith('/') ? resourceBase : `${resourceBase}/`).toString();
2042
- try {
2043
- const res = await fetch(cfgUrl);
2044
- if (!res.ok) {
2045
- const error = new Error(`app-config.json fetch ${res.status} at ${cfgUrl}`);
2046
- console.warn(`[bridge-router] no app-config.json at ${cfgUrl} (${res.status})`);
2047
- onUnreachable?.({ url: cfgUrl, error });
2048
- return {};
2049
- }
2050
- return await res.json();
2051
- }
2052
- catch (error) {
2053
- console.warn('[bridge-router] failed to fetch/parse app-config.json:', error);
2054
- onUnreachable?.({ url: cfgUrl, error });
2055
- return {};
2056
- }
2057
- }
2058
- function buildAppManifest(appConfig, fallbackEntry) {
2059
- const entry = appConfig.app?.entryPagePath || fallbackEntry;
2060
- const hasCompiledPages = !!appConfig.app?.pages?.length;
2061
- const pages = hasCompiledPages ? appConfig.app.pages : [entry];
2062
- const tabBar = appConfig.app?.tabBar && Array.isArray(appConfig.app.tabBar.list) && appConfig.app.tabBar.list.length > 0
2063
- ? appConfig.app.tabBar
2064
- : undefined;
2065
- return {
2066
- entryPagePath: normalizePagePath(entry),
2067
- pages: pages.map(normalizePagePath),
2068
- tabBar,
2069
- // 'app-config' means `pages` above is the real compiled list mount gates
2070
- // can trust; 'fallback' means app-config.json was unreachable and `pages`
2071
- // is just the single requested page — nothing to validate membership
2072
- // against, so every gate keyed on this source lets it through unchanged.
2073
- source: hasCompiledPages ? 'app-config' : 'fallback',
2074
- };
2075
- }
2076
- /**
2077
- * Resolve the root page a spawn actually mounts against the compiled
2078
- * manifest. The request is trusted verbatim unless the manifest is a real
2079
- * compiled one (`source === 'app-config'`) AND the request is absent from its
2080
- * `pages` — in that case the request is unmountable (most commonly a start
2081
- * page removed by a hot reload) and resolution falls back to
2082
- * `manifest.entryPagePath`, or `manifest.pages[0]` when even the entry isn't a
2083
- * member. A `'fallback'` manifest contains only the request itself, so there
2084
- * is trivially nothing to fall back from.
2085
- */
2086
- function resolveRootPagePath(manifest, requestedPagePath) {
2087
- if (manifest.source !== 'app-config' || manifest.pages.includes(requestedPagePath)) {
2088
- return { resolvedPagePath: requestedPagePath, pageFallbackApplied: false };
2089
- }
2090
- const resolvedPagePath = manifest.pages.includes(manifest.entryPagePath)
2091
- ? manifest.entryPagePath
2092
- : manifest.pages[0];
2093
- return { resolvedPagePath, pageFallbackApplied: true };
2094
- }
2095
- function resolvePageWindowConfig(appConfig, pagePath) {
2096
- const normalized = normalizePagePath(pagePath);
2097
- const appWindow = appConfig.app?.window ?? {};
2098
- const pageWindow = appConfig.modules?.[normalized] ?? {};
2099
- return {
2100
- navigationBarTitleText: pageWindow.navigationBarTitleText ?? appWindow.navigationBarTitleText ?? '',
2101
- navigationBarBackgroundColor: pageWindow.navigationBarBackgroundColor ?? appWindow.navigationBarBackgroundColor ?? '#ffffff',
2102
- navigationBarTextStyle: pageWindow.navigationBarTextStyle ?? appWindow.navigationBarTextStyle ?? 'black',
2103
- navigationStyle: pageWindow.navigationStyle ?? appWindow.navigationStyle ?? 'default',
2104
- homeButton: pageWindow.homeButton ?? appWindow.homeButton,
2105
- backgroundColor: pageWindow.backgroundColor ?? appWindow.backgroundColor,
2106
- backgroundTextStyle: pageWindow.backgroundTextStyle ?? appWindow.backgroundTextStyle,
2107
- enablePullDownRefresh: pageWindow.enablePullDownRefresh ?? appWindow.enablePullDownRefresh,
2108
- disableScroll: pageWindow.disableScroll ?? appWindow.disableScroll,
2109
- };
2110
- }
2111
- function isTabPage(appConfig, pagePath) {
2112
- const list = appConfig.app?.tabBar?.list ?? [];
2113
- const normalized = normalizePagePath(pagePath);
2114
- return list.some(item => normalizePagePath(item.pagePath) === normalized);
2115
- }
2116
- function normalizePagePath(p) {
2117
- return p ? p.replace(/^\/+/, '') : '';
2118
- }
2119
- // ── Protocol handlers ───────────────────────────────────────────────────────
2120
- function installResourceProtocolHandlers(ctx, state) {
2121
- const handler = async (request) => {
2122
- const url = new URL(request.url);
2123
- const ap = resolveAppByBridgeId(state, url.hostname);
2124
- if (!ap)
2125
- return new Response('Bridge session not found', { status: 404 });
2126
- const target = new URL(url.pathname.replace(/^\/+/, '') + url.search, ap.resourceBaseUrl);
2127
- return fetch(target);
2128
- };
2129
- const simulatorSession = electronSession.fromPartition(SHARED_MINIAPP_PARTITION);
2130
- try {
2131
- protocol.unhandle('dmb-resource');
2132
- }
2133
- catch { }
2134
- try {
2135
- simulatorSession.protocol.unhandle('dmb-resource');
2136
- }
2137
- catch { }
2138
- protocol.handle('dmb-resource', handler);
2139
- simulatorSession.protocol.handle('dmb-resource', handler);
2140
- // Per-project partition sessions need the SAME resource handler so each
2141
- // project's render/service can load `dmb-resource://…`. Install on every
2142
- // miniapp partition (current + future); track installed sessions for teardown.
2143
- const perProjectSessions = new Set();
2144
- const unregisterConfigurator = registerMiniappSessionConfigurator((sess) => {
2145
- if (perProjectSessions.has(sess))
2146
- return;
2147
- perProjectSessions.add(sess);
2148
- try {
2149
- sess.protocol.unhandle('dmb-resource');
2150
- }
2151
- catch { }
2152
- sess.protocol.handle('dmb-resource', handler);
2153
- });
2154
- ctx.registry.add(() => {
2155
- unregisterConfigurator();
2156
- try {
2157
- protocol.unhandle('dmb-resource');
2158
- }
2159
- catch { }
2160
- try {
2161
- simulatorSession.protocol.unhandle('dmb-resource');
2162
- }
2163
- catch { }
2164
- for (const sess of perProjectSessions) {
2165
- try {
2166
- sess.protocol.unhandle('dmb-resource');
2167
- }
2168
- catch { }
2169
- }
2170
- });
2171
- }
2172
- function makeHostEnv(snapshot) {
2173
- return {
2174
- brand: 'devtools',
2175
- model: 'Electron',
2176
- platform: process.platform,
2177
- system: `${process.platform} ${process.versions.electron || ''}`.trim(),
2178
- version: process.versions.electron || '0.0.0',
2179
- SDKVersion: 'native-host-phase-1',
2180
- pixelRatio: 2,
2181
- screenWidth: 390,
2182
- screenHeight: 844,
2183
- windowWidth: 390,
2184
- windowHeight: 844,
2185
- statusBarHeight: 24,
2186
- language: app.getLocale(),
2187
- theme: 'light',
2188
- ...snapshot,
2189
- };
11
+ // The DevTools integration owns a complete copy of the runtime assets. Keep
12
+ // compatibility contexts (including embedders/tests that assemble the
13
+ // context manually) on that copy instead of falling back to the standalone
14
+ // package's optional dist assets.
15
+ ctx.assets ??= runtimeAssetPaths;
16
+ if (!ctx.events) {
17
+ const events = createRuntimeEvents();
18
+ ctx.events = events;
19
+ events.on('session-status', (payload) => ctx.notify?.sessionRuntimeStatus(payload));
20
+ events.on('app-data-evict', ({ appId, bridgeId }) => {
21
+ ctx.appData?.evictBridge(appId, bridgeId);
22
+ });
23
+ events.on('app-data-message', ({ appId, message }) => {
24
+ ctx.appData?.onServiceToRender(appId, message);
25
+ });
26
+ events.on('storage-changed', ({ appId, change }) => {
27
+ ctx.onServiceStorageChanged?.(appId, change);
28
+ });
29
+ ctx.registry.add(() => events.clear());
30
+ }
31
+ installRuntimeBridgeRouter(ctx);
2190
32
  }
2191
33
  //# sourceMappingURL=bridge-router.js.map