@dimina-kit/devtools 0.4.0-dev.20260703051110 → 0.4.0-dev.20260706064107

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 (58) hide show
  1. package/dist/main/app/app.js +6 -0
  2. package/dist/main/index.bundle.js +445 -49
  3. package/dist/main/ipc/bridge-router.d.ts +8 -0
  4. package/dist/main/ipc/bridge-router.js +352 -38
  5. package/dist/main/services/compile-standby.d.ts +26 -0
  6. package/dist/main/services/compile-standby.js +57 -0
  7. package/dist/main/services/console-forward/index.d.ts +12 -1
  8. package/dist/main/services/console-forward/index.js +99 -1
  9. package/dist/main/services/diagnostics/index.d.ts +49 -0
  10. package/dist/main/services/diagnostics/index.js +61 -0
  11. package/dist/main/services/network-forward/index.js +32 -0
  12. package/dist/main/services/notifications/renderer-notifier.d.ts +39 -0
  13. package/dist/main/services/notifications/renderer-notifier.js +4 -1
  14. package/dist/main/services/projects/project-repository.js +2 -1
  15. package/dist/main/services/views/simulator-session-policy.js +17 -0
  16. package/dist/main/services/workbench-context.d.ts +10 -0
  17. package/dist/main/services/workspace/workspace-service.js +12 -3
  18. package/dist/main/windows/service-host-window/create.d.ts +15 -1
  19. package/dist/main/windows/service-host-window/create.js +11 -3
  20. package/dist/preload/shared/api-compat.js +18 -55
  21. package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
  22. package/dist/preload/windows/main.cjs.map +1 -1
  23. package/dist/preload/windows/simulator.cjs +117 -44
  24. package/dist/preload/windows/simulator.cjs.map +3 -3
  25. package/dist/preload/windows/simulator.js +117 -44
  26. package/dist/renderer/assets/index-BwzPwCmM.js +49 -0
  27. package/dist/renderer/assets/{input-Cjk0wSEh.js → input-CTb_le7F.js} +2 -2
  28. package/dist/renderer/assets/ipc-transport-BPWIV5hA.css +1 -0
  29. package/dist/renderer/assets/{ipc-transport-OZS-KmOW.js → ipc-transport-Ck5Xa8Tu.js} +2 -2
  30. package/dist/renderer/assets/popover-B1Zu2UQu.js +2 -0
  31. package/dist/renderer/assets/{select-BQDXi5ih.js → select-ozSk5Pt6.js} +2 -2
  32. package/dist/renderer/assets/{settings-tP3Px2KR.js → settings-CNwGOI22.js} +2 -2
  33. package/dist/renderer/assets/settings-api-DMbMp1Eq.js +2 -0
  34. package/dist/renderer/assets/{workbenchSettings-COhuFF-i.js → workbenchSettings-Cr4EHo5w.js} +2 -2
  35. package/dist/renderer/entries/main/index.html +6 -6
  36. package/dist/renderer/entries/popover/index.html +5 -5
  37. package/dist/renderer/entries/settings/index.html +5 -5
  38. package/dist/renderer/entries/workbench-settings/index.html +4 -4
  39. package/dist/shared/bridge-channels.d.ts +22 -0
  40. package/dist/shared/ipc-channels.d.ts +3 -0
  41. package/dist/shared/ipc-channels.js +9 -0
  42. package/dist/shared/request-core.d.ts +84 -0
  43. package/dist/shared/request-core.js +168 -0
  44. package/dist/shared/simulator-api-metadata.d.ts +22 -17
  45. package/dist/shared/simulator-api-metadata.js +36 -0
  46. package/dist/shared/types.d.ts +7 -4
  47. package/dist/simulator/assets/{device-shell-CiLAPa0I.js → device-shell-C-wcFq3Z.js} +2 -2
  48. package/dist/simulator/assets/{jsx-runtime-CDK-o-S0.js → jsx-runtime-BDTY6fEq.js} +2 -2
  49. package/dist/simulator/assets/simulator-DU3-fS3v.js +10 -0
  50. package/dist/simulator/assets/simulator-mini-app-DzfMfnVM.js +2 -0
  51. package/dist/simulator/simulator.html +2 -2
  52. package/package.json +5 -5
  53. package/dist/renderer/assets/index-D-ksyN1p.js +0 -49
  54. package/dist/renderer/assets/ipc-transport-D5dKIti1.css +0 -1
  55. package/dist/renderer/assets/popover-CQVvOe90.js +0 -2
  56. package/dist/renderer/assets/settings-api-B6x2mhaD.js +0 -2
  57. package/dist/simulator/assets/simulator-Dvnxry3y.js +0 -10
  58. package/dist/simulator/assets/simulator-mini-app-Bt639XL_.js +0 -2
@@ -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,6 @@ 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;
97
103
  // Same-appId matches prefer the MOST RECENT spawn (Maps preserve insertion
98
104
  // order): after a respawn/reopen the newest session is the live one — the
99
105
  // first match could be a just-superseded session mid-teardown. The loop must
@@ -343,13 +349,28 @@ export function installBridgeRouter(ctx) {
343
349
  // Chrome DevTools (attached to the service host) prefixed `[视图]` — regardless
344
350
  // of whether an automation client is connected. Automation now SUBSCRIBES to
345
351
  // this forwarder instead of clobbering `ctx.guestConsole`.
346
- 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);
347
364
  ctx.consoleForwarder = consoleForwarder;
348
365
  ctx.guestConsole = consoleForwarder;
349
366
  ctx.registry.add(() => {
350
367
  void consoleForwarder.dispose();
351
368
  ctx.consoleForwarder = undefined;
352
369
  ctx.guestConsole = undefined;
370
+ if (ownsDiagnosticsBus) {
371
+ diagnosticsBus.dispose();
372
+ ctx.diagnostics = undefined;
373
+ }
353
374
  });
354
375
  // DeviceShell → main: record the visible top-of-stack page bridgeId so the
355
376
  // accessor above can resolve "the active page". Sender-validated against the
@@ -516,6 +537,64 @@ export function installBridgeRouter(ctx) {
516
537
  });
517
538
  }
518
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
+ }
519
598
  async function handleSpawn(state, ctx, event, opts) {
520
599
  const appId = opts.appId;
521
600
  if (!appId)
@@ -562,10 +641,21 @@ async function handleSpawn(state, ctx, event, opts) {
562
641
  });
563
642
  // app-config.json lives at `<base><appId>/<root>/app-config.json` on the dev
564
643
  // server, or at the local server root for the fallback path.
565
- 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
+ });
566
652
  const manifest = buildAppManifest(appConfig, pagePath);
567
- const rootWindowConfig = resolvePageWindowConfig(appConfig, pagePath);
568
- 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);
569
659
  // Acquire a pre-warmed service-host window when pooling is enabled; otherwise
570
660
  // construct one fresh (default). A pooled/fallback window is warmed on
571
661
  // about:blank and must be navigated to the spawn URL below; the fresh path
@@ -573,13 +663,31 @@ async function handleSpawn(state, ctx, event, opts) {
573
663
  const usedPool = state.pool !== null;
574
664
  let poolEntryId = null;
575
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
+ };
576
684
  if (state.pool) {
577
685
  const acquired = await state.pool.acquire(serviceHostSpec());
578
686
  serviceWindow = acquired.win;
579
687
  poolEntryId = acquired.entryId;
580
688
  }
581
689
  else {
582
- serviceWindow = createServiceHostWindow({
690
+ const freshWindowOptions = {
583
691
  bridgeId,
584
692
  appId,
585
693
  // Same (appId, projectPath) pair the simulator WCV uses, so this project's
@@ -589,12 +697,16 @@ async function handleSpawn(state, ctx, event, opts) {
589
697
  // awaits above would otherwise give the service host a different path than
590
698
  // the simulator WCV was built with, splitting the partition.
591
699
  projectPath: workspaceProjectPath || undefined,
592
- pagePath,
700
+ pagePath: resolvedPagePath,
593
701
  pkgRoot,
594
702
  root,
595
703
  resourceBaseUrl,
596
704
  hostEnvSnapshot: hostEnv,
597
705
  apiNamespaces,
706
+ };
707
+ serviceWindow = createServiceHostWindow({
708
+ ...freshWindowOptions,
709
+ onLoadFailed: err => reportServiceHostNavigationFailed(buildServiceHostSpawnUrl(freshWindowOptions), err),
598
710
  });
599
711
  }
600
712
  const appSession = {
@@ -619,11 +731,14 @@ async function handleSpawn(state, ctx, event, opts) {
619
731
  onServiceBoot: null,
620
732
  listenerBag: createSessionListenerBag(),
621
733
  registryHandle: null,
734
+ running: false,
735
+ launchTimer: null,
736
+ pageFallback: pageFallbackApplied ? { requested: pagePath, resolved: resolvedPagePath } : null,
622
737
  };
623
738
  const rootPage = {
624
739
  bridgeId,
625
740
  appSessionId,
626
- pagePath,
741
+ pagePath: resolvedPagePath,
627
742
  query: opts.query ?? {},
628
743
  isRoot: true,
629
744
  isTab,
@@ -704,16 +819,19 @@ async function handleSpawn(state, ctx, event, opts) {
704
819
  };
705
820
  appSession.onServiceBoot = bootOnServiceLoad;
706
821
  serviceWindow.webContents.on('did-finish-load', bootOnServiceLoad);
707
- void navigateServiceHost(serviceWindow, buildServiceHostSpawnUrl({
822
+ const spawnUrl = buildServiceHostSpawnUrl({
708
823
  bridgeId,
709
824
  appId,
710
- pagePath,
825
+ pagePath: resolvedPagePath,
711
826
  pkgRoot,
712
827
  root,
713
828
  resourceBaseUrl,
714
829
  hostEnvSnapshot: hostEnv,
715
830
  apiNamespaces,
716
- }));
831
+ });
832
+ void navigateServiceHost(serviceWindow, spawnUrl, {
833
+ onLoadFailed: err => reportServiceHostNavigationFailed(spawnUrl, err),
834
+ });
717
835
  }
718
836
  else {
719
837
  // Fresh window: its only navigation is service.html (issued inside
@@ -722,10 +840,33 @@ async function handleSpawn(state, ctx, event, opts) {
722
840
  void bootServiceHost(state, appSession, ctx);
723
841
  });
724
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);
725
864
  return {
726
865
  appSessionId,
727
866
  bridgeId,
728
867
  pagePath,
868
+ resolvedPagePath,
869
+ pageFallbackApplied,
729
870
  serviceWcId: serviceWindow.webContents.id,
730
871
  resourceBaseUrl,
731
872
  manifest,
@@ -741,6 +882,15 @@ async function handlePageOpen(state, event, opts) {
741
882
  throw new Error('[bridge-router] PAGE_OPEN rejected: caller not bound to app session');
742
883
  }
743
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
+ }
744
894
  const bridgeId = opts.bridgeId || newBridgeId();
745
895
  const windowConfig = resolvePageWindowConfig(ap.appConfig, pagePath);
746
896
  const isTab = isTabPage(ap.appConfig, pagePath);
@@ -813,6 +963,10 @@ async function bootServiceHost(state, ap, ctx) {
813
963
  // app's logic.js into the next spawn (the recycled webContents is shared).
814
964
  if (state.appSessions.get(ap.appSessionId) !== ap)
815
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);
816
970
  ap.logicInjected = await injectLogicBundle(ap);
817
971
  if (!ap.logicInjected) {
818
972
  // The compiled logic.js never executed, so `modDefine` registered nothing.
@@ -821,7 +975,9 @@ async function bootServiceHost(state, ap, ctx) {
821
975
  // cause (unreachable resource tree / failed compile / unresolved appId).
822
976
  // Skip it and surface one actionable diagnostic instead; the render side
823
977
  // gates on the same flag in `routeFromRender`.
824
- reportLogicLoadFailure(ap, ctx);
978
+ const reason = reportLogicLoadFailure(ap, ctx);
979
+ clearLaunchTimer(ap);
980
+ pushRuntimeStatus(ctx, ap, { phase: 'launch-failed', code: 'logic-bundle-unreachable', reason });
825
981
  return;
826
982
  }
827
983
  // A root page absent from the compiled manifest — most commonly a page the
@@ -837,7 +993,7 @@ async function bootServiceHost(state, ap, ctx) {
837
993
  const rootPage = ap.pages.get(ap.appSessionId);
838
994
  const rootMissing = !!rootPage && !pageInManifest(ap, rootPage.pagePath);
839
995
  if (rootMissing) {
840
- reportPageNotFound(ap, ctx, rootPage.pagePath);
996
+ reportPageNotFound(ctx, ap.appSessionId, rootPage.pagePath);
841
997
  }
842
998
  else if (rootPage) {
843
999
  // serviceLoaded is flipped only when service responds with
@@ -899,34 +1055,65 @@ async function injectLogicBundle(ap) {
899
1055
  /**
900
1056
  * Emit one actionable diagnostic when the compiled logic bundle could not be
901
1057
  * loaded — the single point where "the app's compiled resource tree is
902
- * unreachable" is known with certainty. Goes to the main-process log AND the
903
- * 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
904
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.
905
1069
  */
906
1070
  function reportLogicLoadFailure(ap, ctx) {
907
1071
  const hint = ap.appId === 'unknown'
908
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.'
909
1073
  : '';
910
- 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} `
911
1076
  + 'The service runtime has no registered modules, so no page can mount.'
912
1077
  + hint
913
1078
  + ` Verify the project compiled successfully and that the resource server serves "${ap.appId}/${ap.root}/".`;
914
- console.error(message);
1079
+ ctx.diagnostics?.report({
1080
+ severity: 'error',
1081
+ code: 'logic-bundle-unreachable',
1082
+ message,
1083
+ appSessionId: ap.appSessionId,
1084
+ });
915
1085
  ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
1086
+ return shortReason;
916
1087
  }
917
1088
  /** Whether `pagePath` still exists in the app's freshly compiled manifest. */
918
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;
919
1097
  return ap.manifest.pages.includes(normalizePagePath(pagePath));
920
1098
  }
921
1099
  /**
922
- * WeChat-devtools-style "page does not exist" diagnostic. Emitted (main log +
923
- * guest Console) when a mount targets a pagePath that is not in the compiled
924
- * 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.
925
1106
  */
926
- function reportPageNotFound(ap, ctx, pagePath) {
927
- 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. `
928
1109
  + '2. Invoking Page() in async task.';
929
- 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
+ });
930
1117
  ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
931
1118
  }
932
1119
  function maybeSendResourceLoaded(ap, page) {
@@ -1005,6 +1192,18 @@ function routeFromRender(state, ap, page, msg, ctx) {
1005
1192
  handleContainerMsg(ap, page, msg, ctx, state);
1006
1193
  }
1007
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
+ }
1008
1207
  function handleContainerMsg(ap, page, msg, ctx, state) {
1009
1208
  switch (msg.type) {
1010
1209
  case 'serviceResourceLoaded':
@@ -1024,12 +1223,12 @@ function handleContainerMsg(ap, page, msg, ctx, state) {
1024
1223
  ap.simulatorWc.send(E.DOM_READY, { bridgeId: page.bridgeId });
1025
1224
  }
1026
1225
  state.emitRenderEvent({ kind: 'domReady', appId: ap.appId, bridgeId: page.bridgeId });
1226
+ markSessionRunning(ctx, ap, page);
1027
1227
  break;
1028
1228
  case 'invokeAPI':
1029
1229
  void handleSimulatorApi(state, ap, page, msg.body, ctx);
1030
1230
  break;
1031
1231
  case 'serviceHostError': {
1032
- console.warn('[bridge-router] service host error:', msg.body);
1033
1232
  // Forward the error to every registered `wx.onError` listener. (The dimina
1034
1233
  // service runtime's App lifecycle is only onLaunch/onShow/onHide, so it
1035
1234
  // does not dispatch `App.onError` — `wx.onError` is the supported path.)
@@ -1037,19 +1236,32 @@ function handleContainerMsg(ap, page, msg, ctx, state) {
1037
1236
  // during lifecycle/event dispatch here.
1038
1237
  const errBody = msg.body;
1039
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
+ });
1040
1248
  for (const id of state.appLifecycle.listeners(ap.appSessionId, 'onError')) {
1041
1249
  sendCallback(ap, id, errArg);
1042
1250
  }
1043
1251
  break;
1044
1252
  }
1045
- case 'consoleLog':
1253
+ case 'consoleLog': {
1046
1254
  // Native-host console capture: the render-host / service-host guest preloads
1047
1255
  // monkeypatch console.* and post each entry here (one case handles both —
1048
1256
  // distinguish via msg.body.source). Forward to the console sink (set by the
1049
1257
  // automation server under native-host) so it can rebroadcast as App.logAdded.
1050
1258
  // Guarded + non-throwing: console capture must never break message routing.
1051
1259
  ctx.guestConsole?.emit(msg.body);
1260
+ const body = msg.body;
1261
+ if (body?.source === 'service')
1262
+ reportServiceUncaughtError(ctx, ap, body);
1052
1263
  break;
1264
+ }
1053
1265
  case 'storageChanged':
1054
1266
  // Native-host SYNC storage liveness: the service-host's `setStorageSync`/etc.
1055
1267
  // write `localStorage` directly (no main round-trip), so they post the change
@@ -1153,6 +1365,14 @@ function handleNavBarApi(ap, page, name, params) {
1153
1365
  sendCallback(ap, params.success, successResult);
1154
1366
  sendCallback(ap, params.complete, successResult);
1155
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
+ }
1156
1376
  // Shared by handleNavActionApi/handleTabActionApi: both forward a same-shaped
1157
1377
  // payload to the simulator window, differing only in the event name and the
1158
1378
  // payload's static `name` union. Simulator-destroyed fails the callback
@@ -1162,11 +1382,44 @@ function sendActionOrFail(ap, eventName, payload, name, params) {
1162
1382
  ap.simulatorWc.send(eventName, payload);
1163
1383
  return;
1164
1384
  }
1165
- const fail = { errMsg: `${name}:fail simulator window destroyed` };
1166
- sendCallback(ap, params.fail, fail);
1167
- sendCallback(ap, params.complete, fail);
1385
+ failActionCallback(ap, params, `${name}:fail simulator window destroyed`);
1168
1386
  }
1169
- function handleNavActionApi(ap, page, name, params) {
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;
1421
+ }
1422
+ }
1170
1423
  const payload = {
1171
1424
  appSessionId: ap.appSessionId,
1172
1425
  bridgeId: page.bridgeId,
@@ -1243,7 +1496,7 @@ async function handleSimulatorApi(state, ap, page, body, ctx) {
1243
1496
  return;
1244
1497
  }
1245
1498
  if (NAV_ACTION_NAMES.has(name)) {
1246
- handleNavActionApi(ap, page, name, params);
1499
+ handleNavActionApi(ap, ctx, page, name, params);
1247
1500
  return;
1248
1501
  }
1249
1502
  if (TAB_ACTION_NAMES.has(name)) {
@@ -1288,12 +1541,17 @@ function forwardApiCallToSimulator(state, ap, page, name, params) {
1288
1541
  const requestId = newRequestId();
1289
1542
  // Persistent subscriptions (`keep: true`, e.g. audioListen) bind their event
1290
1543
  // bridge synchronously but only emit their FIRST response when the first
1291
- // 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
1292
1545
  // one-shot timeout would tear the subscription down before it ever delivers,
1293
1546
  // so keep calls run without a timeout and are reaped on page/app teardown.
1294
1547
  // Recognise persistent subscriptions BY NAME (`audioListen`) too: the service
1295
1548
  // host strips the original `keep: true` before forwarding, so `params.keep` is
1296
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.
1297
1555
  const keep = params.keep === true || isPersistentSimulatorApi(name);
1298
1556
  const timer = keep
1299
1557
  ? undefined
@@ -1308,7 +1566,7 @@ function forwardApiCallToSimulator(state, ap, page, name, params) {
1308
1566
  const fail = { errMsg: `${pending.name}:fail no handler (timeout)` };
1309
1567
  sendCallback(target, pending.callbacks.fail, fail);
1310
1568
  sendCallback(target, pending.callbacks.complete, fail);
1311
- }, API_CALL_TIMEOUT_MS);
1569
+ }, apiCallWatchdogMs(name, params));
1312
1570
  state.pendingApiCalls.set(requestId, {
1313
1571
  appSessionId: ap.appSessionId,
1314
1572
  callbacks,
@@ -1381,7 +1639,20 @@ function handleApiResponse(state, sender, payload) {
1381
1639
  sendCallback(ap, pending.callbacks.complete, payload.result ?? { errMsg: `${pending.name}:ok` });
1382
1640
  }
1383
1641
  else {
1384
- 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
+ };
1385
1656
  sendCallback(ap, pending.callbacks.fail, fail);
1386
1657
  sendCallback(ap, pending.callbacks.complete, fail);
1387
1658
  }
@@ -1658,6 +1929,10 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1658
1929
  if (!ap)
1659
1930
  return;
1660
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);
1661
1936
  // Remove this session's shutdown-fallback registry entry so the registry does
1662
1937
  // not accumulate one stale closure per respawn. Ordered AFTER the delete
1663
1938
  // above: dispose() runs the wrapped fn (disposeAppSession again), which
@@ -1693,7 +1968,14 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1693
1968
  }
1694
1969
  }
1695
1970
  // ── App-config / manifest parsing ───────────────────────────────────────────
1696
- 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) {
1697
1979
  // `resourceBase` is the dir/URL that directly contains `app-config.json`:
1698
1980
  // the dev server's `<base><appId>/<root>/` (http) or the local fallback
1699
1981
  // server root (also http). Both are HTTP, so a single fetch path covers them.
@@ -1701,23 +1983,55 @@ async function loadAppConfig(resourceBase) {
1701
1983
  try {
1702
1984
  const res = await fetch(cfgUrl);
1703
1985
  if (!res.ok) {
1986
+ const error = new Error(`app-config.json fetch ${res.status} at ${cfgUrl}`);
1704
1987
  console.warn(`[bridge-router] no app-config.json at ${cfgUrl} (${res.status})`);
1988
+ onUnreachable?.({ url: cfgUrl, error });
1705
1989
  return {};
1706
1990
  }
1707
1991
  return await res.json();
1708
1992
  }
1709
1993
  catch (error) {
1710
1994
  console.warn('[bridge-router] failed to fetch/parse app-config.json:', error);
1995
+ onUnreachable?.({ url: cfgUrl, error });
1711
1996
  return {};
1712
1997
  }
1713
1998
  }
1714
1999
  function buildAppManifest(appConfig, fallbackEntry) {
1715
2000
  const entry = appConfig.app?.entryPagePath || fallbackEntry;
1716
- 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];
1717
2003
  const tabBar = appConfig.app?.tabBar && Array.isArray(appConfig.app.tabBar.list) && appConfig.app.tabBar.list.length > 0
1718
2004
  ? appConfig.app.tabBar
1719
2005
  : undefined;
1720
- 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 };
1721
2035
  }
1722
2036
  function resolvePageWindowConfig(appConfig, pagePath) {
1723
2037
  const normalized = normalizePagePath(pagePath);