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