@dimina-kit/devtools 0.4.0-dev.20260702182435 → 0.4.0-dev.20260703101348

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 (87) hide show
  1. package/dist/main/app/app.js +5 -25
  2. package/dist/main/app/native-overview.d.ts +27 -0
  3. package/dist/main/app/native-overview.js +44 -0
  4. package/dist/main/index.bundle.js +1217 -735
  5. package/dist/main/ipc/bridge-router.d.ts +8 -0
  6. package/dist/main/ipc/bridge-router.js +526 -173
  7. package/dist/main/ipc/project-fs.js +39 -21
  8. package/dist/main/services/automation/handlers/app.js +143 -109
  9. package/dist/main/services/console-forward/index.d.ts +12 -1
  10. package/dist/main/services/console-forward/index.js +99 -1
  11. package/dist/main/services/diagnostics/index.d.ts +49 -0
  12. package/dist/main/services/diagnostics/index.js +61 -0
  13. package/dist/main/services/mcp/tools/simulator-tools.js +118 -97
  14. package/dist/main/services/network-forward/dispatch-batch.d.ts +22 -0
  15. package/dist/main/services/network-forward/dispatch-batch.js +21 -0
  16. package/dist/main/services/network-forward/index.js +37 -20
  17. package/dist/main/services/notifications/renderer-notifier.d.ts +39 -0
  18. package/dist/main/services/notifications/renderer-notifier.js +4 -1
  19. package/dist/main/services/projects/create-project-service.js +54 -34
  20. package/dist/main/services/projects/project-repository.js +2 -1
  21. package/dist/main/services/projects/types.d.ts +1 -32
  22. package/dist/main/services/service-console/console-api.js +36 -27
  23. package/dist/main/services/simulator-temp-files/disk.js +109 -86
  24. package/dist/main/services/views/simulator-session-policy.js +17 -0
  25. package/dist/main/services/workbench-coi-server.js +58 -45
  26. package/dist/main/services/workbench-context.d.ts +10 -0
  27. package/dist/main/services/workspace/workspace-service.js +12 -3
  28. package/dist/main/utils/logger.d.ts +5 -12
  29. package/dist/main/utils/logger.js +5 -45
  30. package/dist/main/windows/service-host-window/create.d.ts +15 -1
  31. package/dist/main/windows/service-host-window/create.js +11 -3
  32. package/dist/preload/instrumentation/wxml-extract.js +94 -62
  33. package/dist/preload/runtime/main-api-runner.d.ts +1 -1
  34. package/dist/preload/shared/api-compat.js +18 -55
  35. package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
  36. package/dist/preload/windows/main.cjs +284 -565
  37. package/dist/preload/windows/main.cjs.map +2 -2
  38. package/dist/preload/windows/simulator.cjs +154 -83
  39. package/dist/preload/windows/simulator.cjs.map +3 -3
  40. package/dist/preload/windows/simulator.js +154 -83
  41. package/dist/render-host/render-inspect.js +63 -52
  42. package/dist/renderer/assets/index-BwzPwCmM.js +49 -0
  43. package/dist/renderer/assets/{input-Cjk0wSEh.js → input-CTb_le7F.js} +2 -2
  44. package/dist/renderer/assets/ipc-transport-BPWIV5hA.css +1 -0
  45. package/dist/renderer/assets/{ipc-transport-OZS-KmOW.js → ipc-transport-Ck5Xa8Tu.js} +2 -2
  46. package/dist/renderer/assets/popover-B1Zu2UQu.js +2 -0
  47. package/dist/renderer/assets/{select-BQDXi5ih.js → select-ozSk5Pt6.js} +2 -2
  48. package/dist/renderer/assets/{settings-tP3Px2KR.js → settings-CNwGOI22.js} +2 -2
  49. package/dist/renderer/assets/settings-api-DMbMp1Eq.js +2 -0
  50. package/dist/renderer/assets/workbenchSettings-Cr4EHo5w.js +8 -0
  51. package/dist/renderer/entries/main/index.html +6 -6
  52. package/dist/renderer/entries/popover/index.html +5 -5
  53. package/dist/renderer/entries/settings/index.html +5 -5
  54. package/dist/renderer/entries/workbench-settings/index.html +4 -4
  55. package/dist/shared/appdata-accumulator.js +53 -48
  56. package/dist/shared/bridge-channels.d.ts +22 -0
  57. package/dist/shared/ipc-channels.d.ts +3 -0
  58. package/dist/shared/ipc-channels.js +9 -0
  59. package/dist/shared/open-in-editor-resource-path.d.ts +56 -0
  60. package/dist/shared/open-in-editor-resource-path.js +133 -0
  61. package/dist/shared/open-in-editor.d.ts +2 -9
  62. package/dist/shared/open-in-editor.js +8 -93
  63. package/dist/shared/request-core.d.ts +84 -0
  64. package/dist/shared/request-core.js +168 -0
  65. package/dist/shared/simulator-api-metadata.d.ts +22 -17
  66. package/dist/shared/simulator-api-metadata.js +36 -0
  67. package/dist/shared/types.d.ts +20 -4
  68. package/dist/simulator/assets/{device-shell-CUl0ILfn.js → device-shell-C-wcFq3Z.js} +2 -2
  69. package/dist/simulator/assets/{jsx-runtime-CDK-o-S0.js → jsx-runtime-BDTY6fEq.js} +2 -2
  70. package/dist/simulator/assets/simulator-DU3-fS3v.js +10 -0
  71. package/dist/simulator/assets/simulator-mini-app-DzfMfnVM.js +2 -0
  72. package/dist/simulator/simulator.html +2 -2
  73. package/dist/vscode-workbench/assets/__vite-browser-external-CXi6Kz9W.js +1 -0
  74. package/dist/vscode-workbench/assets/{dist-CS7SQP3K.js → dist-TpGpmMGi.js} +3 -3
  75. package/dist/vscode-workbench/assets/{iconv-lite-umd-D3q-1-o6.js → iconv-lite-umd-C9tiCAJa.js} +1 -1
  76. package/dist/vscode-workbench/assets/{index-zjigpZ25.js → index-DJ1HyMZ7.js} +20 -22
  77. package/dist/vscode-workbench/assets/{jschardet-Cu4ufeHq.js → jschardet-DE1jV513.js} +1 -1
  78. package/dist/vscode-workbench/index.html +1 -1
  79. package/package.json +6 -6
  80. package/dist/renderer/assets/index-B-Dqb7G0.js +0 -49
  81. package/dist/renderer/assets/ipc-transport-D5dKIti1.css +0 -1
  82. package/dist/renderer/assets/popover-CQVvOe90.js +0 -2
  83. package/dist/renderer/assets/settings-api-B6x2mhaD.js +0 -2
  84. package/dist/renderer/assets/workbenchSettings-Bim9ol9R.js +0 -8
  85. package/dist/simulator/assets/simulator-Dz8iGcgy.js +0 -10
  86. package/dist/simulator/assets/simulator-mini-app-Bt639XL_.js +0 -2
  87. package/dist/vscode-workbench/assets/__vite-browser-external-B0DTNerG.js +0 -1
@@ -2,7 +2,7 @@ import { app, ipcMain, protocol, session as electronSession, webContents } from
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { BRIDGE_CHANNELS as C, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js';
5
- import { isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js';
5
+ import { apiCallWatchdogMs, isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js';
6
6
  import { devtoolsPackageRoot } from '../utils/paths.js';
7
7
  import { createSessionListenerBag } from './session-listener-bag.js';
8
8
  import { createDebugTap } from '@dimina-kit/electron-deck/main';
@@ -11,6 +11,7 @@ import { buildServiceHostSpawnUrl, createServiceHostWindow, navigateServiceHost,
11
11
  import { ServiceHostPool } from '../services/service-host-pool/pool.js';
12
12
  import { registerMiniappSessionConfigurator, SHARED_MINIAPP_PARTITION, } from '../services/views/miniapp-partition.js';
13
13
  import { createConsoleForwarder } from '../services/console-forward/index.js';
14
+ import { createDiagnosticsBus } from '../services/diagnostics/index.js';
14
15
  import { STORAGE_API_NAMES } from '../services/simulator-storage/index.js';
15
16
  import { buildPageScrollScript } from './page-scroll.js';
16
17
  import { createAppLifecycleController, } from './app-lifecycle.js';
@@ -55,6 +56,13 @@ function rewriteSourceMappingUrl(source, scriptUrl) {
55
56
  return `${source.slice(0, lastIndex)}\n//# sourceMappingURL=${absolute}`;
56
57
  }
57
58
  const STACK_ID = 'stack_0';
59
+ /**
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.
64
+ */
65
+ export const LAUNCH_TIMEOUT_MS = 20_000;
58
66
  /** Hard ceiling on the pre-warm pool, mirroring ServiceHostPool/doc §3.3. */
59
67
  const PREWARM_MAX_POOL_SIZE = 4;
60
68
  /**
@@ -92,8 +100,34 @@ function summarizeBridgeMsg(payload) {
92
100
  const parts = [type, bridgeId ? `bridge=${bridgeId}` : undefined].filter(Boolean);
93
101
  return parts.length > 0 ? parts.join(' ') : undefined;
94
102
  }
95
- /** Default timeout for a simulator-forwarded API call. */
96
- const API_CALL_TIMEOUT_MS = 5_000;
103
+ // Same-appId matches prefer the MOST RECENT spawn (Maps preserve insertion
104
+ // order): after a respawn/reopen the newest session is the live one — the
105
+ // first match could be a just-superseded session mid-teardown. The loop must
106
+ // scan every session (no early break) so the LAST inserted match wins.
107
+ function findAppSessionByAppId(state, appId) {
108
+ let match;
109
+ for (const ap of state.appSessions.values())
110
+ if (ap.appId === appId)
111
+ match = ap;
112
+ return match;
113
+ }
114
+ // No appId hint and no workspace session to disambiguate. Picking by appId is
115
+ // impossible, so fall back to the most-recent spawn — but ONLY when every live
116
+ // session belongs to the same app. Same-appId multiples are a respawn/reopen
117
+ // in progress where the newest is the live one (insertion order = spawn
118
+ // order). Multiple DISTINCT appIds mean a previous project is mid-teardown
119
+ // alongside the new one; any pick is a guess that can resolve the WRONG
120
+ // project's content (the screenshot/inspect-stale-app bug class), so prefer
121
+ // null over a wrong guess.
122
+ function resolveFallbackAppSession(state) {
123
+ let last;
124
+ const distinctAppIds = new Set();
125
+ for (const ap of state.appSessions.values()) {
126
+ last = ap;
127
+ distinctAppIds.add(ap.appId);
128
+ }
129
+ return distinctAppIds.size <= 1 ? last : undefined;
130
+ }
97
131
  /**
98
132
  * Resolve the "current" app session: prefer an explicit appId, else the
99
133
  * workspace's active project, else the only / most-recently-created session.
@@ -101,24 +135,15 @@ const API_CALL_TIMEOUT_MS = 5_000;
101
135
  * rather than assuming a single session.
102
136
  */
103
137
  function resolveCurrentApp(state, ctx, appId) {
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.
107
138
  if (appId) {
108
- let match;
109
- for (const ap of state.appSessions.values())
110
- if (ap.appId === appId)
111
- match = ap;
139
+ const match = findAppSessionByAppId(state, appId);
112
140
  if (match)
113
141
  return match;
114
142
  }
115
143
  const appInfo = ctx.workspace?.getSession?.()?.appInfo;
116
144
  const activeAppId = appInfo?.appId;
117
145
  if (activeAppId) {
118
- let match;
119
- for (const ap of state.appSessions.values())
120
- if (ap.appId === activeAppId)
121
- match = ap;
146
+ const match = findAppSessionByAppId(state, activeAppId);
122
147
  if (match)
123
148
  return match;
124
149
  }
@@ -128,21 +153,7 @@ function resolveCurrentApp(state, ctx, appId) {
128
153
  // while a close is in flight.
129
154
  if (ctx.workspace?.isClosing?.())
130
155
  return undefined;
131
- // No appId hint and no workspace session to disambiguate. Picking by appId is
132
- // impossible, so fall back to the most-recent spawn — but ONLY when every live
133
- // session belongs to the same app. Same-appId multiples are a respawn/reopen
134
- // in progress where the newest is the live one (insertion order = spawn
135
- // order). Multiple DISTINCT appIds mean a previous project is mid-teardown
136
- // alongside the new one; any pick is a guess that can resolve the WRONG
137
- // project's content (the screenshot/inspect-stale-app bug class), so prefer
138
- // null over a wrong guess.
139
- let last;
140
- const distinctAppIds = new Set();
141
- for (const ap of state.appSessions.values()) {
142
- last = ap;
143
- distinctAppIds.add(ap.appId);
144
- }
145
- return distinctAppIds.size <= 1 ? last : undefined;
156
+ return resolveFallbackAppSession(state);
146
157
  }
147
158
  export function installBridgeRouter(ctx) {
148
159
  const state = {
@@ -338,13 +349,28 @@ export function installBridgeRouter(ctx) {
338
349
  // Chrome DevTools (attached to the service host) prefixed `[视图]` — regardless
339
350
  // of whether an automation client is connected. Automation now SUBSCRIBES to
340
351
  // this forwarder instead of clobbering `ctx.guestConsole`.
341
- const consoleForwarder = createConsoleForwarder(bridgeHandle);
352
+ // Authoritative diagnostics bus (see workbench-context.ts): the single place
353
+ // main-synthesized diagnostics are born. The forwarder subscribes to it so
354
+ // every diagnostic also lands in the owning session's embedded DevTools
355
+ // Console panel, not just the main-process log / automation subscribers.
356
+ // A caller (host embedding, or a test) may have already installed one on
357
+ // `ctx` before `installBridgeRouter` runs — respect it instead of
358
+ // clobbering it with a fresh bus; only a bus WE create here is ours to
359
+ // dispose on teardown.
360
+ const ownsDiagnosticsBus = ctx.diagnostics === undefined;
361
+ const diagnosticsBus = ctx.diagnostics ?? createDiagnosticsBus();
362
+ ctx.diagnostics = diagnosticsBus;
363
+ const consoleForwarder = createConsoleForwarder(bridgeHandle, diagnosticsBus);
342
364
  ctx.consoleForwarder = consoleForwarder;
343
365
  ctx.guestConsole = consoleForwarder;
344
366
  ctx.registry.add(() => {
345
367
  void consoleForwarder.dispose();
346
368
  ctx.consoleForwarder = undefined;
347
369
  ctx.guestConsole = undefined;
370
+ if (ownsDiagnosticsBus) {
371
+ diagnosticsBus.dispose();
372
+ ctx.diagnostics = undefined;
373
+ }
348
374
  });
349
375
  // DeviceShell → main: record the visible top-of-stack page bridgeId so the
350
376
  // accessor above can resolve "the active page". Sender-validated against the
@@ -511,6 +537,64 @@ export function installBridgeRouter(ctx) {
511
537
  });
512
538
  }
513
539
  // ── Spawn / Open / Close ─────────────────────────────────────────────────────
540
+ /** Clear a session's launch-timeout watchdog, if still armed. Idempotent. */
541
+ function clearLaunchTimer(ap) {
542
+ if (ap.launchTimer === null)
543
+ return;
544
+ clearTimeout(ap.launchTimer);
545
+ ap.launchTimer = null;
546
+ }
547
+ /**
548
+ * Arm the `LAUNCH_TIMEOUT_MS` watchdog for a freshly spawned session. Fires
549
+ * `'launch-failed'` (`code: 'timeout'`) exactly once, and only if the session
550
+ * is both still registered (not disposed) and has not already reached
551
+ * `'running'` — both settle paths clear the timer themselves, but a timer
552
+ * queued just before either lands could still fire on the old macrotask queue.
553
+ */
554
+ function startLaunchTimer(state, ctx, ap) {
555
+ ap.launchTimer = setTimeout(() => {
556
+ ap.launchTimer = null;
557
+ if (ap.running)
558
+ return;
559
+ if (state.appSessions.get(ap.appSessionId) !== ap)
560
+ return;
561
+ const reason = `Service host did not report readiness within ${LAUNCH_TIMEOUT_MS}ms`;
562
+ ctx.diagnostics?.report({
563
+ severity: 'error',
564
+ code: 'launch-timeout',
565
+ message: reason,
566
+ appSessionId: ap.appSessionId,
567
+ });
568
+ pushRuntimeStatus(ctx, ap, { phase: 'launch-failed', code: 'timeout', reason });
569
+ }, LAUNCH_TIMEOUT_MS);
570
+ }
571
+ /**
572
+ * Flip a session to `'running'` on its root page's first `domReady` — the
573
+ * single source-of-truth transition out of `'launching'`. Guarded by
574
+ * `ap.running` so a later re-render of the same root page (or any non-root
575
+ * page) never re-fires the notification or re-clears an already-cleared timer.
576
+ */
577
+ function markSessionRunning(ctx, ap, page) {
578
+ if (!page.isRoot || ap.running)
579
+ return;
580
+ ap.running = true;
581
+ clearLaunchTimer(ap);
582
+ pushRuntimeStatus(ctx, ap, { phase: 'running' });
583
+ }
584
+ /**
585
+ * The single chokepoint for session runtime-status pushes. Every event
586
+ * carries the session's launch-round facts (appId + pageFallback) alongside
587
+ * the phase change — the renderer replaces its runtimeStatus wholesale, so an
588
+ * event that omitted a still-true fallback would silently blank the fallback
589
+ * banner the moment the phase advances past 'launching'.
590
+ */
591
+ function pushRuntimeStatus(ctx, session, status) {
592
+ ctx.notify?.sessionRuntimeStatus?.({
593
+ appId: session.appId,
594
+ ...status,
595
+ ...(session.pageFallback ? { pageFallback: session.pageFallback } : {}),
596
+ });
597
+ }
514
598
  async function handleSpawn(state, ctx, event, opts) {
515
599
  const appId = opts.appId;
516
600
  if (!appId)
@@ -557,10 +641,21 @@ async function handleSpawn(state, ctx, event, opts) {
557
641
  });
558
642
  // app-config.json lives at `<base><appId>/<root>/app-config.json` on the dev
559
643
  // server, or at the local server root for the fallback path.
560
- const appConfig = await loadAppConfig(resourceServer ? resourceServer.baseUrl : `${resourceBaseUrl}${appId}/${root}/`);
644
+ const appConfig = await loadAppConfig(resourceServer ? resourceServer.baseUrl : `${resourceBaseUrl}${appId}/${root}/`, ({ url, error }) => {
645
+ ctx.diagnostics?.report({
646
+ severity: 'error',
647
+ code: 'app-config-unreachable',
648
+ message: `app-config.json unreachable at ${url}: ${String(error)}`,
649
+ appSessionId,
650
+ });
651
+ });
561
652
  const manifest = buildAppManifest(appConfig, pagePath);
562
- const rootWindowConfig = resolvePageWindowConfig(appConfig, pagePath);
563
- const isTab = isTabPage(appConfig, pagePath);
653
+ const { resolvedPagePath, pageFallbackApplied } = resolveRootPagePath(manifest, pagePath);
654
+ if (pageFallbackApplied) {
655
+ reportPageNotFound(ctx, appSessionId, pagePath, resolvedPagePath);
656
+ }
657
+ const rootWindowConfig = resolvePageWindowConfig(appConfig, resolvedPagePath);
658
+ const isTab = isTabPage(appConfig, resolvedPagePath);
564
659
  // Acquire a pre-warmed service-host window when pooling is enabled; otherwise
565
660
  // construct one fresh (default). A pooled/fallback window is warmed on
566
661
  // about:blank and must be navigated to the spawn URL below; the fresh path
@@ -568,13 +663,31 @@ async function handleSpawn(state, ctx, event, opts) {
568
663
  const usedPool = state.pool !== null;
569
664
  let poolEntryId = null;
570
665
  let serviceWindow;
666
+ // Shared spawn-navigation failure handler (fresh AND pooled paths): without
667
+ // it a loadURL rejection is fully swallowed — did-finish-load never fires and
668
+ // the only signal is the launch watchdog's late timeout instead of the cause.
669
+ const reportServiceHostNavigationFailed = (spawnUrl, err) => {
670
+ const message = `Failed to navigate service host to ${spawnUrl}: ${String(err)}`;
671
+ ctx.diagnostics?.report({
672
+ severity: 'error',
673
+ code: 'service-host-navigation-failed',
674
+ message,
675
+ appSessionId,
676
+ });
677
+ // The session registers after window construction; resolve it fresh so the
678
+ // fresh-path callback (created pre-registration) still clears the watchdog.
679
+ const ap = state.appSessions.get(appSessionId);
680
+ if (ap)
681
+ clearLaunchTimer(ap);
682
+ pushRuntimeStatus(ctx, ap ?? { appId, pageFallback: pageFallbackApplied ? { requested: pagePath, resolved: resolvedPagePath } : null }, { phase: 'launch-failed', code: 'service-host-navigation-failed', reason: message });
683
+ };
571
684
  if (state.pool) {
572
685
  const acquired = await state.pool.acquire(serviceHostSpec());
573
686
  serviceWindow = acquired.win;
574
687
  poolEntryId = acquired.entryId;
575
688
  }
576
689
  else {
577
- serviceWindow = createServiceHostWindow({
690
+ const freshWindowOptions = {
578
691
  bridgeId,
579
692
  appId,
580
693
  // Same (appId, projectPath) pair the simulator WCV uses, so this project's
@@ -584,12 +697,16 @@ async function handleSpawn(state, ctx, event, opts) {
584
697
  // awaits above would otherwise give the service host a different path than
585
698
  // the simulator WCV was built with, splitting the partition.
586
699
  projectPath: workspaceProjectPath || undefined,
587
- pagePath,
700
+ pagePath: resolvedPagePath,
588
701
  pkgRoot,
589
702
  root,
590
703
  resourceBaseUrl,
591
704
  hostEnvSnapshot: hostEnv,
592
705
  apiNamespaces,
706
+ };
707
+ serviceWindow = createServiceHostWindow({
708
+ ...freshWindowOptions,
709
+ onLoadFailed: err => reportServiceHostNavigationFailed(buildServiceHostSpawnUrl(freshWindowOptions), err),
593
710
  });
594
711
  }
595
712
  const appSession = {
@@ -614,11 +731,14 @@ async function handleSpawn(state, ctx, event, opts) {
614
731
  onServiceBoot: null,
615
732
  listenerBag: createSessionListenerBag(),
616
733
  registryHandle: null,
734
+ running: false,
735
+ launchTimer: null,
736
+ pageFallback: pageFallbackApplied ? { requested: pagePath, resolved: resolvedPagePath } : null,
617
737
  };
618
738
  const rootPage = {
619
739
  bridgeId,
620
740
  appSessionId,
621
- pagePath,
741
+ pagePath: resolvedPagePath,
622
742
  query: opts.query ?? {},
623
743
  isRoot: true,
624
744
  isTab,
@@ -699,16 +819,19 @@ async function handleSpawn(state, ctx, event, opts) {
699
819
  };
700
820
  appSession.onServiceBoot = bootOnServiceLoad;
701
821
  serviceWindow.webContents.on('did-finish-load', bootOnServiceLoad);
702
- void navigateServiceHost(serviceWindow, buildServiceHostSpawnUrl({
822
+ const spawnUrl = buildServiceHostSpawnUrl({
703
823
  bridgeId,
704
824
  appId,
705
- pagePath,
825
+ pagePath: resolvedPagePath,
706
826
  pkgRoot,
707
827
  root,
708
828
  resourceBaseUrl,
709
829
  hostEnvSnapshot: hostEnv,
710
830
  apiNamespaces,
711
- }));
831
+ });
832
+ void navigateServiceHost(serviceWindow, spawnUrl, {
833
+ onLoadFailed: err => reportServiceHostNavigationFailed(spawnUrl, err),
834
+ });
712
835
  }
713
836
  else {
714
837
  // Fresh window: its only navigation is service.html (issued inside
@@ -717,10 +840,33 @@ async function handleSpawn(state, ctx, event, opts) {
717
840
  void bootServiceHost(state, appSession, ctx);
718
841
  });
719
842
  }
843
+ // Service-host crash (renderer process gone): a session-scoped hook on the
844
+ // service webContents, bound via the listener bag so `disposeAppSession`
845
+ // detaches it (matching the 'closed'/'destroyed' hooks registered above).
846
+ // Not a `.once` — a pooled service window can recover and crash again
847
+ // across its lifetime, and each crash is independently worth surfacing.
848
+ // Fires whether the crash lands before or after 'running'; only the launch
849
+ // timer needs clearing (it may already be null) since a running session no
850
+ // longer has one armed.
851
+ const onServiceCrashed = () => {
852
+ clearLaunchTimer(appSession);
853
+ ctx.diagnostics?.report({
854
+ severity: 'error',
855
+ code: 'service-host-crashed',
856
+ message: `Service host renderer process gone for appSessionId=${appSessionId}`,
857
+ appSessionId,
858
+ });
859
+ pushRuntimeStatus(ctx, appSession, { phase: 'crashed', code: 'service-host-crashed' });
860
+ };
861
+ appSession.listenerBag.on(serviceWindow.webContents, 'render-process-gone', onServiceCrashed);
862
+ pushRuntimeStatus(ctx, appSession, { phase: 'launching' });
863
+ startLaunchTimer(state, ctx, appSession);
720
864
  return {
721
865
  appSessionId,
722
866
  bridgeId,
723
867
  pagePath,
868
+ resolvedPagePath,
869
+ pageFallbackApplied,
724
870
  serviceWcId: serviceWindow.webContents.id,
725
871
  resourceBaseUrl,
726
872
  manifest,
@@ -736,6 +882,15 @@ async function handlePageOpen(state, event, opts) {
736
882
  throw new Error('[bridge-router] PAGE_OPEN rejected: caller not bound to app session');
737
883
  }
738
884
  const pagePath = normalizePagePath(opts.pagePath);
885
+ // Defense-in-depth against a page absent from the compiled manifest: the
886
+ // nav gate (handleNavActionApi) already blocks normal navigateTo/switchTab
887
+ // traffic upstream, but PAGE_OPEN is reachable directly (any IPC caller), so
888
+ // this refuses to register a PageSession main can never load resources for.
889
+ // Only enforced against a real compiled manifest ('app-config') — a
890
+ // 'fallback' manifest has no compiled truth to validate membership against.
891
+ if (ap.manifest.source === 'app-config' && !ap.manifest.pages.includes(pagePath)) {
892
+ throw new Error(`[bridge-router] PAGE_OPEN rejected: page-not-found "${pagePath}" is not in the compiled manifest`);
893
+ }
739
894
  const bridgeId = opts.bridgeId || newBridgeId();
740
895
  const windowConfig = resolvePageWindowConfig(ap.appConfig, pagePath);
741
896
  const isTab = isTabPage(ap.appConfig, pagePath);
@@ -808,6 +963,10 @@ async function bootServiceHost(state, ap, ctx) {
808
963
  // app's logic.js into the next spawn (the recycled webContents is shared).
809
964
  if (state.appSessions.get(ap.appSessionId) !== ap)
810
965
  return;
966
+ // The service wc just did-finish-load'd (that's how this got invoked) — flush
967
+ // any diagnostic queued for this session (or the global bucket) into its now
968
+ // resolvable console. Safe to call even when nothing is queued.
969
+ ctx.consoleForwarder?.notifyServiceHostReady?.(ap.appSessionId);
811
970
  ap.logicInjected = await injectLogicBundle(ap);
812
971
  if (!ap.logicInjected) {
813
972
  // The compiled logic.js never executed, so `modDefine` registered nothing.
@@ -816,7 +975,9 @@ async function bootServiceHost(state, ap, ctx) {
816
975
  // cause (unreachable resource tree / failed compile / unresolved appId).
817
976
  // Skip it and surface one actionable diagnostic instead; the render side
818
977
  // gates on the same flag in `routeFromRender`.
819
- reportLogicLoadFailure(ap, ctx);
978
+ const reason = reportLogicLoadFailure(ap, ctx);
979
+ clearLaunchTimer(ap);
980
+ pushRuntimeStatus(ctx, ap, { phase: 'launch-failed', code: 'logic-bundle-unreachable', reason });
820
981
  return;
821
982
  }
822
983
  // A root page absent from the compiled manifest — most commonly a page the
@@ -832,7 +993,7 @@ async function bootServiceHost(state, ap, ctx) {
832
993
  const rootPage = ap.pages.get(ap.appSessionId);
833
994
  const rootMissing = !!rootPage && !pageInManifest(ap, rootPage.pagePath);
834
995
  if (rootMissing) {
835
- reportPageNotFound(ap, ctx, rootPage.pagePath);
996
+ reportPageNotFound(ctx, ap.appSessionId, rootPage.pagePath);
836
997
  }
837
998
  else if (rootPage) {
838
999
  // serviceLoaded is flipped only when service responds with
@@ -894,34 +1055,65 @@ async function injectLogicBundle(ap) {
894
1055
  /**
895
1056
  * Emit one actionable diagnostic when the compiled logic bundle could not be
896
1057
  * loaded — the single point where "the app's compiled resource tree is
897
- * unreachable" is known with certainty. Goes to the main-process log AND the
898
- * guest console sink so it surfaces in the devtools Console panel where the
1058
+ * unreachable" is known with certainty. Reported through `ctx.diagnostics`,
1059
+ * which mirrors it to the main-process log AND via `consoleForwarder`'s
1060
+ * subscription — injects it into the service host's own console, so it
1061
+ * actually surfaces in the embedded DevTools Console panel where the
899
1062
  * developer would otherwise see only the misleading `module app not found`.
1063
+ * `guestConsole.emit` is kept alongside for the automation `App.logAdded`
1064
+ * subscriber, which does not consume the diagnostics bus.
1065
+ *
1066
+ * Returns the short (pre-hint) form of the message so `bootServiceHost` can
1067
+ * reuse it verbatim as the `sessionRuntimeStatus` `reason` instead of
1068
+ * duplicating the wording.
900
1069
  */
901
1070
  function reportLogicLoadFailure(ap, ctx) {
902
1071
  const hint = ap.appId === 'unknown'
903
1072
  ? ' 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.'
904
1073
  : '';
905
- const message = `[dimina-kit] Failed to load the mini-program logic bundle from ${logicBundleUrl(ap)}. `
1074
+ const shortReason = `[dimina-kit] Failed to load the mini-program logic bundle from ${logicBundleUrl(ap)}.`;
1075
+ const message = `${shortReason} `
906
1076
  + 'The service runtime has no registered modules, so no page can mount.'
907
1077
  + hint
908
1078
  + ` Verify the project compiled successfully and that the resource server serves "${ap.appId}/${ap.root}/".`;
909
- console.error(message);
1079
+ ctx.diagnostics?.report({
1080
+ severity: 'error',
1081
+ code: 'logic-bundle-unreachable',
1082
+ message,
1083
+ appSessionId: ap.appSessionId,
1084
+ });
910
1085
  ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
1086
+ return shortReason;
911
1087
  }
912
1088
  /** Whether `pagePath` still exists in the app's freshly compiled manifest. */
913
1089
  function pageInManifest(ap, pagePath) {
1090
+ // A 'fallback' manifest (app-config.json unreachable) holds only the spawn
1091
+ // request — no compiled truth to check membership against. Every mount gate
1092
+ // keyed on manifest membership must agree: the nav/PAGE_OPEN front gates
1093
+ // already let a fallback manifest through, so this back gate (render
1094
+ // loadResource) must too, or a page they admitted silently never loads.
1095
+ if (ap.manifest.source !== 'app-config')
1096
+ return true;
914
1097
  return ap.manifest.pages.includes(normalizePagePath(pagePath));
915
1098
  }
916
1099
  /**
917
- * WeChat-devtools-style "page does not exist" diagnostic. Emitted (main log +
918
- * guest Console) when a mount targets a pagePath that is not in the compiled
919
- * manifest most commonly a page the developer deleted, then hot-reloaded.
1100
+ * WeChat-devtools-style "page does not exist" diagnostic. Reported through
1101
+ * `ctx.diagnostics` (main log + injected into the embedded Console panel via
1102
+ * `consoleForwarder`) when a mount targets a pagePath that is not in the
1103
+ * compiled manifest — most commonly a page the developer deleted, then
1104
+ * hot-reloaded. `guestConsole.emit` is kept alongside for the automation
1105
+ * subscriber.
920
1106
  */
921
- function reportPageNotFound(ap, ctx, pagePath) {
922
- const message = `Page[${pagePath}] not found. May be caused by: 1. Forgetting to add page route in app.json. `
1107
+ function reportPageNotFound(ctx, appSessionId, pagePath, fallbackTo) {
1108
+ const base = `Page[${pagePath}] not found. May be caused by: 1. Forgetting to add page route in app.json. `
923
1109
  + '2. Invoking Page() in async task.';
924
- console.error(`[bridge-router] ${message}`);
1110
+ const message = fallbackTo ? `${base} Falling back to "${fallbackTo}".` : base;
1111
+ ctx.diagnostics?.report({
1112
+ severity: 'error',
1113
+ code: 'page-not-found',
1114
+ message,
1115
+ appSessionId,
1116
+ });
925
1117
  ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
926
1118
  }
927
1119
  function maybeSendResourceLoaded(ap, page) {
@@ -1000,6 +1192,18 @@ function routeFromRender(state, ap, page, msg, ctx) {
1000
1192
  handleContainerMsg(ap, page, msg, ctx, state);
1001
1193
  }
1002
1194
  }
1195
+ /**
1196
+ * Report 'service-uncaught-error' for a consoleLog(source:'service') body —
1197
+ * the service preload's uncaught error/unhandledrejection post. CDP's
1198
+ * `Runtime.consoleAPICalled` (services/service-console) never observes these
1199
+ * (they never call `console.*`), so this diagnostics report is the only way
1200
+ * one reaches the Console panel / main log.
1201
+ */
1202
+ function reportServiceUncaughtError(ctx, ap, body) {
1203
+ const severity = body.level === 'error' ? 'error' : body.level === 'warn' ? 'warn' : 'info';
1204
+ const message = Array.isArray(body.args) ? body.args.map(a => String(a)).join(' ') : String(body.args ?? '');
1205
+ ctx.diagnostics?.report({ severity, code: 'service-uncaught-error', message, appSessionId: ap.appSessionId });
1206
+ }
1003
1207
  function handleContainerMsg(ap, page, msg, ctx, state) {
1004
1208
  switch (msg.type) {
1005
1209
  case 'serviceResourceLoaded':
@@ -1019,12 +1223,12 @@ function handleContainerMsg(ap, page, msg, ctx, state) {
1019
1223
  ap.simulatorWc.send(E.DOM_READY, { bridgeId: page.bridgeId });
1020
1224
  }
1021
1225
  state.emitRenderEvent({ kind: 'domReady', appId: ap.appId, bridgeId: page.bridgeId });
1226
+ markSessionRunning(ctx, ap, page);
1022
1227
  break;
1023
1228
  case 'invokeAPI':
1024
1229
  void handleSimulatorApi(state, ap, page, msg.body, ctx);
1025
1230
  break;
1026
1231
  case 'serviceHostError': {
1027
- console.warn('[bridge-router] service host error:', msg.body);
1028
1232
  // Forward the error to every registered `wx.onError` listener. (The dimina
1029
1233
  // service runtime's App lifecycle is only onLaunch/onShow/onHide, so it
1030
1234
  // does not dispatch `App.onError` — `wx.onError` is the supported path.)
@@ -1032,19 +1236,32 @@ function handleContainerMsg(ap, page, msg, ctx, state) {
1032
1236
  // during lifecycle/event dispatch here.
1033
1237
  const errBody = msg.body;
1034
1238
  const errArg = errBody?.message ?? msg.body;
1239
+ // Reported through the diagnostics bus (main log + Console-panel
1240
+ // injection); `ctx.diagnostics.report` replaces the bare `console.warn`
1241
+ // this case used to make directly.
1242
+ ctx.diagnostics?.report({
1243
+ severity: 'error',
1244
+ code: 'service-host-error',
1245
+ message: String(errArg),
1246
+ appSessionId: ap.appSessionId,
1247
+ });
1035
1248
  for (const id of state.appLifecycle.listeners(ap.appSessionId, 'onError')) {
1036
1249
  sendCallback(ap, id, errArg);
1037
1250
  }
1038
1251
  break;
1039
1252
  }
1040
- case 'consoleLog':
1253
+ case 'consoleLog': {
1041
1254
  // Native-host console capture: the render-host / service-host guest preloads
1042
1255
  // monkeypatch console.* and post each entry here (one case handles both —
1043
1256
  // distinguish via msg.body.source). Forward to the console sink (set by the
1044
1257
  // automation server under native-host) so it can rebroadcast as App.logAdded.
1045
1258
  // Guarded + non-throwing: console capture must never break message routing.
1046
1259
  ctx.guestConsole?.emit(msg.body);
1260
+ const body = msg.body;
1261
+ if (body?.source === 'service')
1262
+ reportServiceUncaughtError(ctx, ap, body);
1047
1263
  break;
1264
+ }
1048
1265
  case 'storageChanged':
1049
1266
  // Native-host SYNC storage liveness: the service-host's `setStorageSync`/etc.
1050
1267
  // write `localStorage` directly (no main round-trip), so they post the change
@@ -1136,86 +1353,160 @@ const APP_LIFECYCLE_UNREGISTER = {
1136
1353
  offAppHide: 'onAppHide',
1137
1354
  offError: 'onError',
1138
1355
  };
1139
- async function handleSimulatorApi(state, ap, page, body, ctx) {
1140
- const name = String(body.name ?? '');
1141
- const params = normalizeParams(body.params);
1142
- if (NAV_BAR_API_NAMES.has(name)) {
1143
- if (!ap.simulatorWc.isDestroyed()) {
1144
- ap.simulatorWc.send(E.NAV_BAR, {
1145
- bridgeId: page.bridgeId,
1146
- name,
1147
- params,
1148
- });
1149
- }
1150
- const successResult = { errMsg: `${name}:ok` };
1151
- sendCallback(ap, params.success, successResult);
1152
- sendCallback(ap, params.complete, successResult);
1153
- return;
1154
- }
1155
- if (NAV_ACTION_NAMES.has(name)) {
1156
- const payload = {
1157
- appSessionId: ap.appSessionId,
1356
+ function handleNavBarApi(ap, page, name, params) {
1357
+ if (!ap.simulatorWc.isDestroyed()) {
1358
+ ap.simulatorWc.send(E.NAV_BAR, {
1158
1359
  bridgeId: page.bridgeId,
1159
- name: name,
1360
+ name,
1160
1361
  params,
1161
- callbacks: extractCallbacks(params),
1162
- };
1163
- if (!ap.simulatorWc.isDestroyed()) {
1164
- ap.simulatorWc.send(E.NAV_ACTION, payload);
1165
- }
1166
- else {
1167
- const fail = { errMsg: `${name}:fail simulator window destroyed` };
1168
- sendCallback(ap, params.fail, fail);
1169
- sendCallback(ap, params.complete, fail);
1170
- }
1362
+ });
1363
+ }
1364
+ const successResult = { errMsg: `${name}:ok` };
1365
+ sendCallback(ap, params.success, successResult);
1366
+ sendCallback(ap, params.complete, successResult);
1367
+ }
1368
+ // Fails a nav/tab action's service-side callback through the same success/
1369
+ // complete dispatch every other API branch uses — the one mechanism for
1370
+ // answering a call that never reaches (or never should reach) the simulator.
1371
+ function failActionCallback(ap, params, errMsg) {
1372
+ const fail = { errMsg };
1373
+ sendCallback(ap, params.fail, fail);
1374
+ sendCallback(ap, params.complete, fail);
1375
+ }
1376
+ // Shared by handleNavActionApi/handleTabActionApi: both forward a same-shaped
1377
+ // payload to the simulator window, differing only in the event name and the
1378
+ // payload's static `name` union. Simulator-destroyed fails the callback
1379
+ // directly since there is no ack path to fail through otherwise.
1380
+ function sendActionOrFail(ap, eventName, payload, name, params) {
1381
+ if (!ap.simulatorWc.isDestroyed()) {
1382
+ ap.simulatorWc.send(eventName, payload);
1171
1383
  return;
1172
1384
  }
1173
- if (TAB_ACTION_NAMES.has(name)) {
1174
- const payload = {
1175
- appSessionId: ap.appSessionId,
1176
- bridgeId: page.bridgeId,
1177
- name: name,
1178
- params,
1179
- callbacks: extractCallbacks(params),
1180
- };
1181
- if (!ap.simulatorWc.isDestroyed()) {
1182
- ap.simulatorWc.send(E.TAB_ACTION, payload);
1183
- }
1184
- else {
1185
- const fail = { errMsg: `${name}:fail simulator window destroyed` };
1186
- sendCallback(ap, params.fail, fail);
1187
- sendCallback(ap, params.complete, fail);
1385
+ failActionCallback(ap, params, `${name}:fail simulator window destroyed`);
1386
+ }
1387
+ /** The bare page path a nav/tab action targets (query-stripped, normalized), or undefined for an action carrying none (navigateBack). */
1388
+ function extractNavTargetPagePath(params) {
1389
+ const raw = params.url;
1390
+ if (typeof raw !== 'string' || !raw)
1391
+ return undefined;
1392
+ return normalizePagePath(raw.split('?')[0] ?? raw);
1393
+ }
1394
+ /**
1395
+ * Whether a nav/tab action's target page is mountable — checked ONLY against
1396
+ * a real compiled manifest (`source === 'app-config'`); a `'fallback'`
1397
+ * manifest can't tell mountable from not, so it lets everything through.
1398
+ */
1399
+ function checkNavTarget(ap, name, targetPagePath) {
1400
+ if (ap.manifest.source !== 'app-config')
1401
+ return { ok: true };
1402
+ if (!ap.manifest.pages.includes(targetPagePath)) {
1403
+ return { ok: false, errMsg: `${name}:fail page "${targetPagePath}" is not found`, reportNotFound: true };
1404
+ }
1405
+ if (name === 'switchTab') {
1406
+ const inTabBar = ap.manifest.tabBar?.list.some(item => normalizePagePath(item.pagePath) === targetPagePath) ?? false;
1407
+ if (!inTabBar)
1408
+ return { ok: false, errMsg: 'switchTab:fail can not switch to no-tabBar page' };
1409
+ }
1410
+ return { ok: true };
1411
+ }
1412
+ function handleNavActionApi(ap, ctx, page, name, params) {
1413
+ const targetPagePath = extractNavTargetPagePath(params);
1414
+ if (targetPagePath !== undefined) {
1415
+ const verdict = checkNavTarget(ap, name, targetPagePath);
1416
+ if (!verdict.ok) {
1417
+ if (verdict.reportNotFound)
1418
+ reportPageNotFound(ctx, ap.appSessionId, targetPagePath);
1419
+ failActionCallback(ap, params, verdict.errMsg);
1420
+ return;
1188
1421
  }
1189
- return;
1190
1422
  }
1191
- // App-level lifecycle listeners (wx.onAppShow / onAppHide / onError + off*).
1192
- // The service encodes the listener as a keep callback id in `params.success`;
1193
- // we store it and re-fire on main-window foreground/background (the driver in
1194
- // installBridgeRouter) and on serviceHostError. `off*` clears the event's
1195
- // listeners for the session.
1423
+ const payload = {
1424
+ appSessionId: ap.appSessionId,
1425
+ bridgeId: page.bridgeId,
1426
+ name: name,
1427
+ params,
1428
+ callbacks: extractCallbacks(params),
1429
+ };
1430
+ sendActionOrFail(ap, E.NAV_ACTION, payload, name, params);
1431
+ }
1432
+ function handleTabActionApi(ap, page, name, params) {
1433
+ const payload = {
1434
+ appSessionId: ap.appSessionId,
1435
+ bridgeId: page.bridgeId,
1436
+ name: name,
1437
+ params,
1438
+ callbacks: extractCallbacks(params),
1439
+ };
1440
+ sendActionOrFail(ap, E.TAB_ACTION, payload, name, params);
1441
+ }
1442
+ // App-level lifecycle listeners (wx.onAppShow / onAppHide / onError + off*).
1443
+ // The service encodes the listener as a keep callback id in `params.success`;
1444
+ // we store it and re-fire on main-window foreground/background (the driver in
1445
+ // installBridgeRouter) and on serviceHostError. `off*` clears the event's
1446
+ // listeners for the session. Returns true when `name` matched a lifecycle
1447
+ // register/unregister call (fully handled), false to fall through.
1448
+ function handleAppLifecycleToggle(state, ap, name, params) {
1196
1449
  const lifecycleRegister = APP_LIFECYCLE_REGISTER[name];
1197
1450
  if (lifecycleRegister) {
1198
1451
  state.appLifecycle.register(ap.appSessionId, lifecycleRegister, params.success);
1199
- return;
1452
+ return true;
1200
1453
  }
1201
1454
  const lifecycleUnregister = APP_LIFECYCLE_UNREGISTER[name];
1202
1455
  if (lifecycleUnregister) {
1203
1456
  // off(cb) carries the same evtId as the original on(cb) → removes just that
1204
1457
  // listener; off() with no callback carries no id → clears the whole event.
1205
1458
  state.appLifecycle.unregister(ap.appSessionId, lifecycleUnregister, params.success);
1459
+ return true;
1460
+ }
1461
+ return false;
1462
+ }
1463
+ // pageScrollTo acts on the page's render guest (scroll its document), which
1464
+ // only the main process can reach — run the scroll script in the invoking
1465
+ // page's render webContents rather than forwarding to the simulator.
1466
+ function handlePageScrollApi(ap, page, params) {
1467
+ const renderWc = page.renderWc;
1468
+ if (renderWc && !renderWc.isDestroyed()) {
1469
+ void renderWc.executeJavaScript(buildPageScrollScript(params)).catch(() => { });
1470
+ }
1471
+ const successResult = { errMsg: 'pageScrollTo:ok' };
1472
+ sendCallback(ap, params.success, successResult);
1473
+ sendCallback(ap, params.complete, successResult);
1474
+ }
1475
+ // Shared try/invoke/callback pattern used by the storageApi and
1476
+ // ctx.simulatorApis branches: relay the resolved value through
1477
+ // success+complete, or the thrown error through fail+complete with a
1478
+ // namespaced errMsg.
1479
+ async function invokeSimulatorApiAndCallback(ap, name, params, invoke) {
1480
+ try {
1481
+ const result = await invoke();
1482
+ sendCallback(ap, params.success, result);
1483
+ sendCallback(ap, params.complete, result);
1484
+ }
1485
+ catch (error) {
1486
+ const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
1487
+ sendCallback(ap, params.fail, failResult);
1488
+ sendCallback(ap, params.complete, failResult);
1489
+ }
1490
+ }
1491
+ async function handleSimulatorApi(state, ap, page, body, ctx) {
1492
+ const name = String(body.name ?? '');
1493
+ const params = normalizeParams(body.params);
1494
+ if (NAV_BAR_API_NAMES.has(name)) {
1495
+ handleNavBarApi(ap, page, name, params);
1496
+ return;
1497
+ }
1498
+ if (NAV_ACTION_NAMES.has(name)) {
1499
+ handleNavActionApi(ap, ctx, page, name, params);
1500
+ return;
1501
+ }
1502
+ if (TAB_ACTION_NAMES.has(name)) {
1503
+ handleTabActionApi(ap, page, name, params);
1206
1504
  return;
1207
1505
  }
1208
- // pageScrollTo acts on the page's render guest (scroll its document), which
1209
- // only the main process can reach — run the scroll script in the invoking
1210
- // page's render webContents rather than forwarding to the simulator.
1506
+ if (handleAppLifecycleToggle(state, ap, name, params))
1507
+ return;
1211
1508
  if (name === 'pageScrollTo') {
1212
- const renderWc = page.renderWc;
1213
- if (renderWc && !renderWc.isDestroyed()) {
1214
- void renderWc.executeJavaScript(buildPageScrollScript(params)).catch(() => { });
1215
- }
1216
- const successResult = { errMsg: 'pageScrollTo:ok' };
1217
- sendCallback(ap, params.success, successResult);
1218
- sendCallback(ap, params.complete, successResult);
1509
+ handlePageScrollApi(ap, page, params);
1219
1510
  return;
1220
1511
  }
1221
1512
  // Native-host storage unification: route async wx.setStorage/getStorage/etc.
@@ -1224,16 +1515,8 @@ async function handleSimulatorApi(state, ap, page, body, ctx) {
1224
1515
  // http:// origin. Without this, async writes and sync writes diverge across
1225
1516
  // two origins even for the running mini-app.
1226
1517
  if (ctx.storageApi && STORAGE_API_NAMES.has(name)) {
1227
- try {
1228
- const result = await ctx.storageApi.invoke(ap.appId, name, params);
1229
- sendCallback(ap, params.success, result);
1230
- sendCallback(ap, params.complete, result);
1231
- }
1232
- catch (error) {
1233
- const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
1234
- sendCallback(ap, params.fail, failResult);
1235
- sendCallback(ap, params.complete, failResult);
1236
- }
1518
+ const storageApi = ctx.storageApi;
1519
+ await invokeSimulatorApiAndCallback(ap, name, params, () => storageApi.invoke(ap.appId, name, params));
1237
1520
  return;
1238
1521
  }
1239
1522
  // Main-process registry (downstream-registered host APIs via
@@ -1242,16 +1525,7 @@ async function handleSimulatorApi(state, ap, page, body, ctx) {
1242
1525
  // MiniApp owns the DOM-touching defaults (wx.getSystemInfo, chooseImage,
1243
1526
  // chooseMedia, fs.*, …) and the bridge-router can't run those itself.
1244
1527
  if (ctx.simulatorApis.has(name)) {
1245
- try {
1246
- const result = await ctx.simulatorApis.invoke(name, params);
1247
- sendCallback(ap, params.success, result);
1248
- sendCallback(ap, params.complete, result);
1249
- }
1250
- catch (error) {
1251
- const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
1252
- sendCallback(ap, params.fail, failResult);
1253
- sendCallback(ap, params.complete, failResult);
1254
- }
1528
+ await invokeSimulatorApiAndCallback(ap, name, params, () => ctx.simulatorApis.invoke(name, params));
1255
1529
  return;
1256
1530
  }
1257
1531
  forwardApiCallToSimulator(state, ap, page, name, params);
@@ -1267,12 +1541,17 @@ function forwardApiCallToSimulator(state, ap, page, name, params) {
1267
1541
  const requestId = newRequestId();
1268
1542
  // Persistent subscriptions (`keep: true`, e.g. audioListen) bind their event
1269
1543
  // bridge synchronously but only emit their FIRST response when the first
1270
- // event fires (which can be well past the 5s no-handler window). Arming the
1544
+ // event fires (which can be well past the no-handler window). Arming the
1271
1545
  // one-shot timeout would tear the subscription down before it ever delivers,
1272
1546
  // so keep calls run without a timeout and are reaped on page/app teardown.
1273
1547
  // Recognise persistent subscriptions BY NAME (`audioListen`) too: the service
1274
1548
  // host strips the original `keep: true` before forwarding, so `params.keep` is
1275
1549
  // gone by the time we route the call. See shared/simulator-api-metadata.ts.
1550
+ //
1551
+ // The watchdog window is per-call (apiCallWatchdogMs): network-budget APIs
1552
+ // (request/downloadFile/uploadFile) get their wx timeout budget + grace so a
1553
+ // slow-but-alive HTTP call is never torn down before its handler's own
1554
+ // deadline; everything else keeps the flat 5s missing-handler window.
1276
1555
  const keep = params.keep === true || isPersistentSimulatorApi(name);
1277
1556
  const timer = keep
1278
1557
  ? undefined
@@ -1287,7 +1566,7 @@ function forwardApiCallToSimulator(state, ap, page, name, params) {
1287
1566
  const fail = { errMsg: `${pending.name}:fail no handler (timeout)` };
1288
1567
  sendCallback(target, pending.callbacks.fail, fail);
1289
1568
  sendCallback(target, pending.callbacks.complete, fail);
1290
- }, API_CALL_TIMEOUT_MS);
1569
+ }, apiCallWatchdogMs(name, params));
1291
1570
  state.pendingApiCalls.set(requestId, {
1292
1571
  appSessionId: ap.appSessionId,
1293
1572
  callbacks,
@@ -1360,7 +1639,20 @@ function handleApiResponse(state, sender, payload) {
1360
1639
  sendCallback(ap, pending.callbacks.complete, payload.result ?? { errMsg: `${pending.name}:ok` });
1361
1640
  }
1362
1641
  else {
1363
- const fail = { errMsg: payload.errMsg ?? `${pending.name}:fail` };
1642
+ // The router is a transport, not an author: the simulator-side fail result
1643
+ // (errno etc.) must reach the service callbacks intact, so spread it
1644
+ // through instead of rebuilding a bare { errMsg }. errMsg is guaranteed
1645
+ // non-empty — `||` (not `??`) so an empty string (e.g. an HTTP/2 response
1646
+ // whose statusText is always '') falls through payload.errMsg →
1647
+ // result.errMsg → the `${name}:fail` default instead of surfacing blank.
1648
+ const result = payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result)
1649
+ ? payload.result
1650
+ : undefined;
1651
+ const resultErrMsg = typeof result?.errMsg === 'string' ? result.errMsg : undefined;
1652
+ const fail = {
1653
+ ...result,
1654
+ errMsg: payload.errMsg || resultErrMsg || `${pending.name}:fail`,
1655
+ };
1364
1656
  sendCallback(ap, pending.callbacks.fail, fail);
1365
1657
  sendCallback(ap, pending.callbacks.complete, fail);
1366
1658
  }
@@ -1544,34 +1836,22 @@ function disposePageSession(state, ap, page) {
1544
1836
  ap.pages.delete(page.bridgeId);
1545
1837
  state.pageSessions.delete(page.bridgeId);
1546
1838
  }
1547
- async function disposeAppSession(state, appSessionId, opts = {}) {
1548
- const ap = state.appSessions.get(appSessionId);
1549
- if (!ap)
1550
- return;
1551
- state.appSessions.delete(appSessionId);
1552
- // Remove this session's shutdown-fallback registry entry so the registry does
1553
- // not accumulate one stale closure per respawn. Ordered AFTER the delete
1554
- // above: dispose() runs the wrapped fn (disposeAppSession again), which
1555
- // early-returns now that the session is gone — no recursion, no double-run.
1556
- // (At whole-app shutdown the registry marks the entry released before calling
1557
- // the fn, so this dispose() is a no-op then.)
1558
- const registryHandle = ap.registryHandle;
1559
- ap.registryHandle = null;
1560
- void registryHandle?.dispose();
1561
- state.appLifecycle.dispose(appSessionId);
1562
- // Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the
1563
- // page teardown below progressively empties (and finally clears).
1564
- state.evictAppDataBridges(ap);
1565
- // Drain any pending API calls owned by this app session. One-shot calls
1566
- // normally self-clean on response/timeout, but persistent (`keep: true`)
1567
- // subscriptions (e.g. audioListen) live with their timer cleared until
1568
- // teardown — without this they would leak in pendingApiCalls forever.
1839
+ // Drain any pending API calls owned by this app session. One-shot calls
1840
+ // normally self-clean on response/timeout, but persistent (`keep: true`)
1841
+ // subscriptions (e.g. audioListen) live with their timer cleared until
1842
+ // teardown — without this they would leak in pendingApiCalls forever.
1843
+ function drainPendingApiCallsForSession(state, appSessionId) {
1569
1844
  for (const [requestId, pending] of state.pendingApiCalls) {
1570
1845
  if (pending.appSessionId !== appSessionId)
1571
1846
  continue;
1572
1847
  clearTimeout(pending.timer);
1573
1848
  state.pendingApiCalls.delete(requestId);
1574
1849
  }
1850
+ }
1851
+ // Closes every page's render guest and drops its wc/pageSessions bindings.
1852
+ // Caller clears `ap.pages` itself right after — this only tears each page
1853
+ // down, it doesn't touch the map.
1854
+ function closeSessionPages(state, ap) {
1575
1855
  for (const page of ap.pages.values()) {
1576
1856
  if (page.renderWc && !page.renderWc.isDestroyed()) {
1577
1857
  state.wcIdToBridgeId.delete(page.renderWc.id);
@@ -1588,15 +1868,11 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1588
1868
  }
1589
1869
  state.pageSessions.delete(page.bridgeId);
1590
1870
  }
1591
- ap.pages.clear();
1592
- // Detach every session-scoped hook on emitters that outlive this session —
1593
- // the shared simulator WCV's 'destroyed' and the service window's 'closed'
1594
- // BEFORE the window is released/closed below: a pool-recycled window must
1595
- // not re-trigger this session's teardown from a stale 'closed', and the
1596
- // simulator wc must not accumulate one dead hook per soft reload until the
1597
- // MaxListeners warning fires. Idempotent with teardown being triggered BY
1598
- // one of these hooks (removing a fired once() is a no-op).
1599
- ap.listenerBag.dispose();
1871
+ }
1872
+ // Three-way release of the session's service-host window: pool release (soft
1873
+ // reuse), pool reclaim of an externally-closed window, or a plain close for
1874
+ // the unpooled path.
1875
+ async function releaseServiceWindow(state, ap, opts) {
1600
1876
  if (ap.poolEntryId !== null && state.pool && !opts.serviceAlreadyClosed) {
1601
1877
  // Soft reuse (foundation.md §4.3): the pooled service-host webContents keeps
1602
1878
  // its wc.id but is about to run a NEW app session. Reset its Connection
@@ -1623,12 +1899,14 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1623
1899
  // pool only auto-reclaims on render-process-gone — so without this the in-use
1624
1900
  // entry leaks in the pool forever, permanently shrinking capacity. Reclaim
1625
1901
  // the slot explicitly (the window is already gone; releaseDestroyed won't
1626
- // touch it). (Audit A1)
1902
+ // touch it).
1627
1903
  state.pool.releaseDestroyed(ap.poolEntryId);
1628
1904
  }
1629
1905
  else if (!opts.serviceAlreadyClosed && !ap.serviceWindow.isDestroyed()) {
1630
1906
  ap.serviceWindow.close();
1631
1907
  }
1908
+ }
1909
+ function unbindSessionFromSharedMaps(state, ap, appSessionId) {
1632
1910
  // Value-checked unbind for the service wc: this delete runs after the
1633
1911
  // pool-release await above, so on the pool path the next spawn may have
1634
1912
  // already re-acquired the SAME window and rebound its wc id — only remove
@@ -1645,6 +1923,42 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1645
1923
  if (hosted.size === 0)
1646
1924
  state.simulatorWcIdToAppSessionIds.delete(ap.simulatorWc.id);
1647
1925
  }
1926
+ }
1927
+ async function disposeAppSession(state, appSessionId, opts = {}) {
1928
+ const ap = state.appSessions.get(appSessionId);
1929
+ if (!ap)
1930
+ return;
1931
+ state.appSessions.delete(appSessionId);
1932
+ // Prevent a leaked/stale watchdog: without this, a session disposed while
1933
+ // still 'launching' (e.g. project close mid-launch) would fire its timer
1934
+ // `LAUNCH_TIMEOUT_MS` later against an already-torn-down session.
1935
+ clearLaunchTimer(ap);
1936
+ // Remove this session's shutdown-fallback registry entry so the registry does
1937
+ // not accumulate one stale closure per respawn. Ordered AFTER the delete
1938
+ // above: dispose() runs the wrapped fn (disposeAppSession again), which
1939
+ // early-returns now that the session is gone — no recursion, no double-run.
1940
+ // (At whole-app shutdown the registry marks the entry released before calling
1941
+ // the fn, so this dispose() is a no-op then.)
1942
+ const registryHandle = ap.registryHandle;
1943
+ ap.registryHandle = null;
1944
+ void registryHandle?.dispose();
1945
+ state.appLifecycle.dispose(appSessionId);
1946
+ // Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the
1947
+ // page teardown below progressively empties (and finally clears).
1948
+ state.evictAppDataBridges(ap);
1949
+ drainPendingApiCallsForSession(state, appSessionId);
1950
+ closeSessionPages(state, ap);
1951
+ ap.pages.clear();
1952
+ // Detach every session-scoped hook on emitters that outlive this session —
1953
+ // the shared simulator WCV's 'destroyed' and the service window's 'closed' —
1954
+ // BEFORE the window is released/closed below: a pool-recycled window must
1955
+ // not re-trigger this session's teardown from a stale 'closed', and the
1956
+ // simulator wc must not accumulate one dead hook per soft reload until the
1957
+ // MaxListeners warning fires. Idempotent with teardown being triggered BY
1958
+ // one of these hooks (removing a fired once() is a no-op).
1959
+ ap.listenerBag.dispose();
1960
+ await releaseServiceWindow(state, ap, opts);
1961
+ unbindSessionFromSharedMaps(state, ap, appSessionId);
1648
1962
  // Only the local fallback server needs closing; the dev-server base is owned
1649
1963
  // by the workspace session, not this app session.
1650
1964
  if (ap.resourceServer) {
@@ -1654,7 +1968,14 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1654
1968
  }
1655
1969
  }
1656
1970
  // ── App-config / manifest parsing ───────────────────────────────────────────
1657
- async function loadAppConfig(resourceBase) {
1971
+ /**
1972
+ * Fetch+parse `app-config.json`. Pure (no `ctx`, no diagnostics dependency) —
1973
+ * a non-2xx response or a parse/network failure reports through `onUnreachable`
1974
+ * (the caller routes it into `ctx.diagnostics`) and returns `{}` either way, so
1975
+ * the spawn flow always continues with a manifest that just falls back to a
1976
+ * single-page/no-tabBar shape (`buildAppManifest`'s fallback).
1977
+ */
1978
+ async function loadAppConfig(resourceBase, onUnreachable) {
1658
1979
  // `resourceBase` is the dir/URL that directly contains `app-config.json`:
1659
1980
  // the dev server's `<base><appId>/<root>/` (http) or the local fallback
1660
1981
  // server root (also http). Both are HTTP, so a single fetch path covers them.
@@ -1662,23 +1983,55 @@ async function loadAppConfig(resourceBase) {
1662
1983
  try {
1663
1984
  const res = await fetch(cfgUrl);
1664
1985
  if (!res.ok) {
1986
+ const error = new Error(`app-config.json fetch ${res.status} at ${cfgUrl}`);
1665
1987
  console.warn(`[bridge-router] no app-config.json at ${cfgUrl} (${res.status})`);
1988
+ onUnreachable?.({ url: cfgUrl, error });
1666
1989
  return {};
1667
1990
  }
1668
1991
  return await res.json();
1669
1992
  }
1670
1993
  catch (error) {
1671
1994
  console.warn('[bridge-router] failed to fetch/parse app-config.json:', error);
1995
+ onUnreachable?.({ url: cfgUrl, error });
1672
1996
  return {};
1673
1997
  }
1674
1998
  }
1675
1999
  function buildAppManifest(appConfig, fallbackEntry) {
1676
2000
  const entry = appConfig.app?.entryPagePath || fallbackEntry;
1677
- const pages = appConfig.app?.pages?.length ? appConfig.app.pages : [entry];
2001
+ const hasCompiledPages = !!appConfig.app?.pages?.length;
2002
+ const pages = hasCompiledPages ? appConfig.app.pages : [entry];
1678
2003
  const tabBar = appConfig.app?.tabBar && Array.isArray(appConfig.app.tabBar.list) && appConfig.app.tabBar.list.length > 0
1679
2004
  ? appConfig.app.tabBar
1680
2005
  : undefined;
1681
- return { entryPagePath: normalizePagePath(entry), pages: pages.map(normalizePagePath), tabBar };
2006
+ return {
2007
+ entryPagePath: normalizePagePath(entry),
2008
+ pages: pages.map(normalizePagePath),
2009
+ tabBar,
2010
+ // 'app-config' means `pages` above is the real compiled list mount gates
2011
+ // can trust; 'fallback' means app-config.json was unreachable and `pages`
2012
+ // is just the single requested page — nothing to validate membership
2013
+ // against, so every gate keyed on this source lets it through unchanged.
2014
+ source: hasCompiledPages ? 'app-config' : 'fallback',
2015
+ };
2016
+ }
2017
+ /**
2018
+ * Resolve the root page a spawn actually mounts against the compiled
2019
+ * manifest. The request is trusted verbatim unless the manifest is a real
2020
+ * compiled one (`source === 'app-config'`) AND the request is absent from its
2021
+ * `pages` — in that case the request is unmountable (most commonly a start
2022
+ * page removed by a hot reload) and resolution falls back to
2023
+ * `manifest.entryPagePath`, or `manifest.pages[0]` when even the entry isn't a
2024
+ * member. A `'fallback'` manifest contains only the request itself, so there
2025
+ * is trivially nothing to fall back from.
2026
+ */
2027
+ function resolveRootPagePath(manifest, requestedPagePath) {
2028
+ if (manifest.source !== 'app-config' || manifest.pages.includes(requestedPagePath)) {
2029
+ return { resolvedPagePath: requestedPagePath, pageFallbackApplied: false };
2030
+ }
2031
+ const resolvedPagePath = manifest.pages.includes(manifest.entryPagePath)
2032
+ ? manifest.entryPagePath
2033
+ : manifest.pages[0];
2034
+ return { resolvedPagePath, pageFallbackApplied: true };
1682
2035
  }
1683
2036
  function resolvePageWindowConfig(appConfig, pagePath) {
1684
2037
  const normalized = normalizePagePath(pagePath);