@dimina-kit/devtools 0.4.0-dev.20260701083540 → 0.4.0-dev.20260702145840

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 (81) hide show
  1. package/dist/main/app/app.js +11 -0
  2. package/dist/main/app/max-listeners-diagnostic.d.ts +32 -0
  3. package/dist/main/app/max-listeners-diagnostic.js +63 -0
  4. package/dist/main/index.bundle.js +1657 -1158
  5. package/dist/main/ipc/bridge-router.d.ts +33 -4
  6. package/dist/main/ipc/bridge-router.js +200 -51
  7. package/dist/main/ipc/session-listener-bag.d.ts +41 -0
  8. package/dist/main/ipc/session-listener-bag.js +40 -0
  9. package/dist/main/ipc/simulator.js +5 -5
  10. package/dist/main/ipc/views.js +8 -19
  11. package/dist/main/services/automation/wait-active-page.d.ts +4 -2
  12. package/dist/main/services/elements-forward/index.js +19 -0
  13. package/dist/main/services/network-forward/index.js +13 -0
  14. package/dist/main/services/render-inspect/index.d.ts +9 -0
  15. package/dist/main/services/render-inspect/index.js +13 -1
  16. package/dist/main/services/simulator-storage/index.d.ts +9 -0
  17. package/dist/main/services/simulator-storage/index.js +26 -1
  18. package/dist/main/services/simulator-wxml/index.js +117 -11
  19. package/dist/main/services/views/apply-view-ops.d.ts +11 -0
  20. package/dist/main/services/views/apply-view-ops.js +22 -0
  21. package/dist/main/services/views/destroy-child-view.d.ts +9 -0
  22. package/dist/main/services/views/destroy-child-view.js +23 -0
  23. package/dist/main/services/views/host-toolbar-view.d.ts +21 -0
  24. package/dist/main/services/views/host-toolbar-view.js +249 -0
  25. package/dist/main/services/views/inject-when-ready.d.ts +41 -0
  26. package/dist/main/services/views/inject-when-ready.js +62 -0
  27. package/dist/main/services/views/native-simulator-devtools-host.d.ts +26 -0
  28. package/dist/main/services/views/native-simulator-devtools-host.js +484 -0
  29. package/dist/main/services/views/native-simulator-view.d.ts +28 -0
  30. package/dist/main/services/views/native-simulator-view.js +428 -0
  31. package/dist/main/services/views/overlay-panels-view.d.ts +29 -0
  32. package/dist/main/services/views/overlay-panels-view.js +150 -0
  33. package/dist/main/services/views/placement-reconciler.d.ts +73 -0
  34. package/dist/main/services/views/placement-reconciler.js +120 -0
  35. package/dist/main/services/views/placement-test-driver.d.ts +25 -0
  36. package/dist/main/services/views/placement-test-driver.js +48 -0
  37. package/dist/main/services/views/resolve-project-editor-target.d.ts +13 -0
  38. package/dist/main/services/views/resolve-project-editor-target.js +33 -0
  39. package/dist/main/services/views/view-manager.d.ts +28 -70
  40. package/dist/main/services/views/view-manager.js +66 -1514
  41. package/dist/main/services/views/workbench-view.d.ts +15 -0
  42. package/dist/main/services/views/workbench-view.js +170 -0
  43. package/dist/main/services/workbench-context.d.ts +10 -0
  44. package/dist/preload/windows/host-toolbar-runtime.cjs +6 -13
  45. package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
  46. package/dist/preload/windows/main.cjs +10 -1
  47. package/dist/preload/windows/main.cjs.map +2 -2
  48. package/dist/preload/windows/simulator.cjs +6 -3
  49. package/dist/preload/windows/simulator.cjs.map +2 -2
  50. package/dist/preload/windows/simulator.js +6 -3
  51. package/dist/render-host/render-inspect.js +35 -1
  52. package/dist/renderer/assets/index-ChF9V4r-.js +49 -0
  53. package/dist/renderer/assets/{input-DR7bfyQE.js → input-Cjk0wSEh.js} +2 -2
  54. package/dist/renderer/assets/ipc-transport-D5dKIti1.css +1 -0
  55. package/dist/renderer/assets/{ipc-transport-3Arg8jPC.js → ipc-transport-OZS-KmOW.js} +2 -2
  56. package/dist/renderer/assets/{popover-CPZX3MrT.js → popover-CQVvOe90.js} +2 -2
  57. package/dist/renderer/assets/{select-BxyPlVyJ.js → select-BQDXi5ih.js} +2 -2
  58. package/dist/renderer/assets/{settings-api-CtPPJF-F.js → settings-api-B6x2mhaD.js} +2 -2
  59. package/dist/renderer/assets/{settings-CG_OR03F.js → settings-tP3Px2KR.js} +2 -2
  60. package/dist/renderer/assets/{workbenchSettings-ClqCBGOa.js → workbenchSettings-Bim9ol9R.js} +2 -2
  61. package/dist/renderer/entries/main/index.html +6 -6
  62. package/dist/renderer/entries/popover/index.html +5 -5
  63. package/dist/renderer/entries/settings/index.html +5 -5
  64. package/dist/renderer/entries/workbench-settings/index.html +4 -4
  65. package/dist/service-host/sync-api-patch.js +33 -3
  66. package/dist/shared/bridge-channels.d.ts +17 -0
  67. package/dist/shared/bridge-channels.js +9 -0
  68. package/dist/shared/ipc-channels.d.ts +29 -14
  69. package/dist/shared/ipc-channels.js +16 -17
  70. package/dist/shared/ipc-schemas.d.ts +25 -17
  71. package/dist/shared/ipc-schemas.js +38 -22
  72. package/dist/shared/view-ids.d.ts +19 -0
  73. package/dist/shared/view-ids.js +25 -0
  74. package/dist/simulator/assets/{bridge-channels-BUQ5AbvJ.js → bridge-channels-zhsumfky.js} +2 -2
  75. package/dist/simulator/assets/{device-shell-CT3LRnTB.js → device-shell-CUl0ILfn.js} +2 -2
  76. package/dist/simulator/assets/{simulator-BizhGGE3.js → simulator-Dz8iGcgy.js} +5 -5
  77. package/dist/simulator/assets/{simulator-mini-app-BfNuDF3R.js → simulator-mini-app-Bt639XL_.js} +2 -2
  78. package/dist/simulator/simulator.html +2 -1
  79. package/package.json +4 -4
  80. package/dist/renderer/assets/index-HE9Xr9bQ.js +0 -49
  81. package/dist/renderer/assets/ipc-transport-Dt0jR7Ir.css +0 -1
@@ -1,1536 +1,88 @@
1
- import { ipcMain, nativeTheme, shell, WebContentsView, webContents } from 'electron';
2
- import fs from 'node:fs';
3
- import path from 'path';
4
- import { cjsSiblingPreloadPath, mainPreloadPath } from '../../utils/paths.js';
5
- import { simDeskBg } from '../../utils/theme.js';
6
- import { applyNavigationHardening, handleWindowOpenExternal, } from '../../windows/navigation-hardening.js';
7
- import { SimulatorCustomApiBridgeChannel } from '../../../shared/ipc-channels.js';
8
- import { buildDevtoolsProjectSourceLinksScript, decodeOpenInEditorUrl, projectSourceContextFromServiceHostUrl, resourceUrlToProjectRelativePath, } from '../../../shared/open-in-editor.js';
9
1
  import { createSafeAreaController } from '../safe-area/index.js';
10
- import { buildCustomizeTabsScript } from './devtools-tabs.js';
11
- import { installElementsForward } from '../elements-forward/index.js';
12
- import { installServiceConsoleForward } from '../service-console/index.js';
13
- import * as layout from '../layout/index.js';
14
- import { handleCustomApiBridgeRequest, } from '../simulator/custom-apis.js';
15
- import { configureMiniappSession, miniappPartition } from './miniapp-partition.js';
16
- import { acquireHostToolbarSessionRuntime, releaseHostToolbarSessionRuntime, } from './host-toolbar-session-runtime.js';
17
- import { createHostToolbarPortChannel, } from './host-toolbar-port-channel.js';
18
- import { parseRoute } from '../../../shared/simulator-route.js';
19
- import { HEADER_H, HOST_TOOLBAR_RUNTIME_MARKER } from '../../../shared/constants.js';
20
- /**
21
- * Resolve a DevTools source request against the service-host URL that created
22
- * the inspected app. Its pkgRoot is authoritative; the workspace is only a
23
- * stale-session consistency guard.
24
- */
25
- export function resolveProjectEditorTarget(serviceHostUrl, activeProjectRoot, req, isFile = (absolutePath) => fs.statSync(absolutePath).isFile()) {
26
- const sourceContext = projectSourceContextFromServiceHostUrl(serviceHostUrl, activeProjectRoot);
27
- if (!sourceContext)
28
- return null;
29
- const rel = resourceUrlToProjectRelativePath(req.url, sourceContext);
30
- if (!rel)
31
- return null;
32
- const absolute = path.resolve(sourceContext.projectRoot, ...rel.split('/'));
33
- const fromRoot = path.relative(path.resolve(sourceContext.projectRoot), absolute);
34
- if (!fromRoot || fromRoot.startsWith('..') || path.isAbsolute(fromRoot))
35
- return null;
36
- try {
37
- if (!isFile(absolute))
38
- return null;
39
- }
40
- catch {
41
- return null;
42
- }
43
- return {
44
- path: rel,
45
- line: typeof req.line === 'number' ? req.line + 1 : undefined,
46
- column: typeof req.column === 'number' ? req.column + 1 : undefined,
47
- };
48
- }
2
+ import { createPlacementReconciler } from './placement-reconciler.js';
3
+ import { createWorkbenchView } from './workbench-view.js';
4
+ import { createDevtoolsHost } from './native-simulator-devtools-host.js';
5
+ import { createHostToolbarView } from './host-toolbar-view.js';
6
+ import { createOverlayPanelsView } from './overlay-panels-view.js';
7
+ import { createNativeSimulatorView } from './native-simulator-view.js';
8
+ export { resolveProjectEditorTarget, } from './resolve-project-editor-target.js';
49
9
  /**
50
10
  * Build a ViewManager bound to the given context. The returned object is the
51
11
  * only component allowed to instantiate or add/remove overlay WebContentsViews.
52
12
  *
53
- * All view-related mutable state lives inside this closure and is not exposed
54
- * on the context object.
13
+ * All view-related mutable state lives inside the per-domain factory closures
14
+ * (and the single placement reconciler) below, never on the context object.
55
15
  */
56
16
  export function createViewManager(ctx) {
57
17
  // CSS env(safe-area-inset-*) simulation for render-host guests (per device).
58
- // Driven from did-attach-webview below; re-pushed on device change via
59
- // reapplySafeArea. Torn down in disposeAll.
18
+ // Driven from did-attach-webview in the simulator domain and re-pushed on
19
+ // device change via reapplySafeArea. Torn down in disposeAll.
60
20
  const safeArea = createSafeAreaController({ connections: ctx.connections });
61
- // ── Private mutable state ───────────────────────────────────────────────
62
- let simulatorView = null;
63
- let simulatorViewAdded = false;
64
- // NATIVE-HOST ONLY: the simulator content WebContentsView (the DeviceShell
65
- // host). In the default path the simulator is a renderer `<webview>` and this
66
- // stays null. Positioned in the simulator panel region (left of the splitter)
67
- // while `simulatorView` above hosts its DevTools in the right panel region.
68
- let nativeSimulatorView = null;
69
- let nativeSimulatorViewAdded = false;
70
- let nativeSimulatorProjectPath = null;
71
- let settleNativeSimulatorReady = null;
72
- // Model A: the renderer's measured inner-screen rect is the SOLE authority for
73
- // the native simulator WCV bounds (see docs/simulator-render-architecture.md).
74
- // Cache the last rect so a report that lands before attachNativeSimulator (the
75
- // project-open ordering) is not lost — attach replays it instead of using a
76
- // coarse panel-size fallback (which raced and caused the clip/surround flips).
77
- let lastRendererRect = null;
78
- // NATIVE-HOST ONLY. The `ipcMain.on` listener that services the
79
- // `__diminaCustomApis` bridge for the current native simulator webContents
80
- // (see `attachNativeCustomApiBridge`). Tracked so we can remove it before
81
- // tearing down / re-attaching the view (otherwise listeners leak across
82
- // relaunch cycles).
83
- let nativeCustomApiBridgeHandler = null;
84
- // NATIVE-HOST ONLY. The current device zoom as a factor (zoomPercent/100),
85
- // last reported by the renderer via setNativeSimulatorViewBounds. Stored so
86
- // nested render-host `<webview>` guests attached AFTER a zoom change still
87
- // pick up the correct scale in `did-attach-webview`. Defaults to 1 (100%).
88
- let currentZoomFactor = 1;
89
- let settingsView = null;
90
- let settingsViewAdded = false;
91
- let popoverView = null;
92
- let simulatorWebContentsId = null;
93
- // NATIVE-HOST ONLY. The webContents the right-panel Chrome DevTools front-end
94
- // currently inspects. We point it at the SERVICE HOST (logic layer) — the
95
- // hidden BrowserWindow where the mini-app's page code runs (`console.log`,
96
- // `wx.request`, Sources/Network(fetch) all live there). The UI/view layer
97
- // (Elements/Styles/WXML tree) is served separately by the native WXML panel +
98
- // render-guest highlight chain (`simulator-storage`/`simulator-wxml`), so a
99
- // single DevTools front-end is enough. The service window can be swapped on
100
- // respawn (pre-warm pool recycles it), so this is re-resolved fresh via
101
- // `ctx.bridge.getServiceWc()` and re-pointed on every render-side event.
102
- let nativeDevtoolsSourceWc = null;
103
- let unsubscribeNativeRenderEvents = null;
104
- // Disposer for the Elements-forward feature (routes the front-end's Elements
105
- // CDP traffic at the active render guest). Installed in
106
- // `attachNativeSimulatorDevtoolsHost`, stopped on detach / host destroyed.
107
- let stopElementsForward = null;
108
- // Disposer for the service-layer console capture (CDP `consoleAPICalled` on the
109
- // service host wc → console fan-out). Installed when the DevTools is pointed at
110
- // a service host, stopped when that source is closed / swapped.
111
- let stopServiceConsole = null;
112
- let nativeDevtoolsRetryTimer = null;
113
- let nativeDevtoolsRetryToken = 0;
114
- // Renderer-driven overlay bounds for the simulator DevTools view — the SOLE
115
- // mount/geometry authority (no static-layout fallback). A zero-area
116
- // rectangle means "hide" — the overlay is removed from the contentView but
117
- // its WebContents stays alive.
118
- let simulatorBoundsOverride = null;
119
- // ── Embedded workbench editor WebContentsView ────────────────────────
120
- // The opt-in VS Code workbench hosting the 'editor' dock slot. Lazily created
121
- // by `attachWorkbench` from the COI server URL; its bounds ride the renderer
122
- // 'editor'-slot anchor (forward anchor, like the simulator DevTools overlay).
123
- let workbenchView = null;
124
- let workbenchViewAdded = false;
125
- // Whether the devtools-theme → workbench-theme `nativeTheme` listener is live.
126
- // Bound lazily on first workbench attach, removed on detach.
127
- let workbenchThemeSyncBound = false;
128
- // COI server base URL for the workbench, stored by `setWorkbenchSource`. The
129
- // heavy WebContentsView load is deferred until the 'editor' slot first becomes
130
- // visible (first non-zero `setWorkbenchBounds`) so it never sits on the app
131
- // boot critical path (which would delay preload/window-ready and trip the e2e
132
- // health check into a relaunch).
133
- let workbenchUrl = null;
134
- // ── Host-controllable toolbar WebContentsView ───────────────────────────
135
- // A strip above the devtools header that the downstream host loads its own
136
- // content into and fully controls. Bounds come from a renderer DOM anchor
137
- // (forward anchor, like the simulator DevTools overlay); its height is
138
- // dynamic via a reverse size-advertiser the toolbar's own renderer drives.
139
- let hostToolbarView = null;
140
- let hostToolbarPreloadOverride = null;
141
- let hostToolbarViewAdded = false;
142
- // Whether THIS manager holds a reference on the shared defaultSession
143
- // registration of the toolbar-runtime preload (see
144
- // host-toolbar-session-runtime.ts). Acquired on first toolbar need,
145
- // released exactly once in disposeAll — a manager that never used the
146
- // toolbar must not decrement a ref it never took.
147
- let hostToolbarRuntimeAcquired = false;
148
- // Placeholder height authority: 'auto' = advertiser reports forward to the
149
- // renderer; { fixed } = host-pinned, advertiser reports are dropped.
150
- let hostToolbarHeightMode = 'auto';
151
- // Last toolbar height NOTIFIED to the main-window renderer — the replay
152
- // source behind `getHostToolbarHeight()`. Updated ONLY inside
153
- // `notifyHostToolbarHeight` so the retained value can never diverge from
154
- // what the renderer was told (an advertiser report dropped by a `{ fixed }`
155
- // pin must not pollute it, and a setHeightMode validation reject leaves it
156
- // untouched).
157
- let hostToolbarLastHeight = 0;
158
- // Gated narrow channel to the toolbar PAGE (per-load MessagePort handshake;
159
- // see host-toolbar-port-channel.ts). Control-level registry — created with
160
- // the manager so onMessage() works before any toolbar view exists.
161
- const hostToolbarPort = createHostToolbarPortChannel({
162
- isCurrent: (wc) => liveHostToolbarWebContents() === wc,
21
+ // The single level-triggered placement reconciler every view domain shares —
22
+ // the sole owner of placement state (docs/view-placement-reconciler.md). Each
23
+ // domain registers exactly one view slot with it.
24
+ const reconciler = createPlacementReconciler(ctx);
25
+ const workbench = createWorkbenchView(ctx, reconciler);
26
+ const devtoolsHost = createDevtoolsHost(ctx, reconciler, {
27
+ openFileInWorkbench: workbench.openFileInWorkbench,
163
28
  });
164
- // ── Internal helpers ────────────────────────────────────────────────────
165
- function destroyViewInternal(view) {
166
- if (!view)
167
- return;
168
- if (!ctx.windows.mainWindow.isDestroyed()) {
169
- try {
170
- ctx.windows.mainWindow.contentView.removeChildView(view);
171
- }
172
- catch { /* already removed */ }
173
- }
174
- try {
175
- if (!view.webContents.isDestroyed()) {
176
- view.webContents.close();
177
- }
178
- }
179
- catch { /* ignore */ }
180
- }
181
- // Renderer publishes width/height = 0 to mean "hide overlay" — the
182
- // surrounding React panel is unmounted/collapsed. We keep the cached
183
- // value (so future republishes win over legacy layout) but remove the
184
- // child view from the contentView until a non-empty rect arrives.
185
- function isHidden(b) {
186
- return b.width <= 0 || b.height <= 0;
187
- }
188
- function setSimulatorDevtoolsBounds(bounds) {
189
- simulatorBoundsOverride = bounds;
190
- if (!simulatorView || simulatorView.webContents.isDestroyed())
191
- return;
192
- if (ctx.windows.mainWindow.isDestroyed())
193
- return;
194
- if (isHidden(bounds)) {
195
- if (simulatorViewAdded) {
196
- try {
197
- ctx.windows.mainWindow.contentView.removeChildView(simulatorView);
198
- }
199
- catch { /* already removed */ }
200
- simulatorViewAdded = false;
201
- }
202
- return;
203
- }
204
- if (!simulatorViewAdded) {
205
- ctx.windows.mainWindow.contentView.addChildView(simulatorView);
206
- simulatorViewAdded = true;
207
- // Re-attaching this base overlay moved it to the top of the z-stack; keep
208
- // any open settings/popover above it.
209
- raiseTopOverlays();
210
- }
211
- simulatorView.setBounds(bounds);
212
- }
213
- // ── Embedded workbench editor WebContentsView ────────────────────────
214
- /** Current devtools color scheme, mirrored into the workbench's theme. */
215
- function workbenchThemeScheme() {
216
- return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
217
- }
218
- // Push the live devtools scheme into the workbench whenever it flips. The
219
- // workbench is a plain isolated http document, so drive its exposed
220
- // `__WB_SET_THEME` setter over executeJavaScript (mirrors openFileInWorkbench).
221
- // The setter only exists once the workbench's configuration service is
222
- // initialized; before then the URL-query initial value already covers the
223
- // current scheme, and a missing setter here is a tolerated no-op.
224
- function pushWorkbenchTheme() {
225
- if (!workbenchView || workbenchView.webContents.isDestroyed())
226
- return;
227
- const wc = workbenchView.webContents;
228
- if (typeof wc.executeJavaScript !== 'function')
229
- return;
230
- const script = `window.__WB_SET_THEME && window.__WB_SET_THEME(${JSON.stringify(workbenchThemeScheme())})`;
231
- wc.executeJavaScript(script, true).catch(() => { });
232
- }
233
- async function attachWorkbench(url) {
234
- if (workbenchView)
235
- return;
236
- const view = new WebContentsView({
237
- webPreferences: {
238
- nodeIntegration: false,
239
- contextIsolation: true,
240
- sandbox: false,
241
- },
242
- });
243
- workbenchView = view;
244
- // Track devtools theme flips for the lifetime of the workbench view only —
245
- // registered here (not at construction) so test electron mocks that omit
246
- // `nativeTheme` and never open the editor stay unaffected. Removed in
247
- // detachWorkbench.
248
- if (!workbenchThemeSyncBound) {
249
- nativeTheme.on('updated', pushWorkbenchTheme);
250
- workbenchThemeSyncBound = true;
251
- }
252
- // The workbench bundle loads arbitrary URLs (docs links, etc.); route popups
253
- // + cross-origin in-place navigation to the OS browser (mirror the host
254
- // toolbar / native simulator hardening).
255
- try {
256
- view.webContents.setWindowOpenHandler(({ url: target }) => handleWindowOpenExternal(target));
257
- }
258
- catch { /* stub may lack it */ }
259
- ctx.windows.mainWindow.contentView.addChildView(view);
260
- workbenchViewAdded = true;
261
- // Keep settings/popover above the freshly-added base overlay.
262
- raiseTopOverlays();
263
- // Hand the workbench the current devtools scheme as a URL query so its very
264
- // first paint already matches (the runtime setter only exists post-init).
265
- const loadUrl = `${url}index.html?theme=${workbenchThemeScheme()}`;
266
- await view.webContents.loadURL(loadUrl).catch((err) => {
267
- console.error('[workbench] attachWorkbench — loadURL failed', err);
268
- });
269
- }
270
- /** Store the COI base URL; the heavy load happens lazily on first show. */
271
- function setWorkbenchSource(url) {
272
- workbenchUrl = url;
273
- }
274
- function setWorkbenchBounds(bounds) {
275
- if (ctx.windows.mainWindow.isDestroyed())
276
- return;
277
- // Zero-area rect means "hide" — remove the child view but keep its
278
- // WebContents (and the workbench's loaded state) alive. Never triggers the
279
- // lazy load: a hidden slot must not pull the workbench onto screen.
280
- if (isHidden(bounds)) {
281
- if (workbenchView && workbenchViewAdded && !workbenchView.webContents.isDestroyed()) {
282
- try {
283
- ctx.windows.mainWindow.contentView.removeChildView(workbenchView);
284
- }
285
- catch { /* already removed */ }
286
- workbenchViewAdded = false;
287
- }
288
- return;
289
- }
290
- // First time the 'editor' slot becomes visible: lazily create + load the
291
- // workbench (off the boot critical path). attachWorkbench assigns
292
- // workbenchView + addChildView synchronously, so bounds apply immediately
293
- // while the bundle loads in the background.
294
- if (!workbenchView && workbenchUrl) {
295
- void attachWorkbench(workbenchUrl);
296
- }
297
- if (!workbenchView || workbenchView.webContents.isDestroyed())
298
- return;
299
- if (!workbenchViewAdded) {
300
- ctx.windows.mainWindow.contentView.addChildView(workbenchView);
301
- workbenchViewAdded = true;
302
- // Re-attaching this base overlay moved it to the top of the z-stack; keep
303
- // any open settings/popover above it.
304
- raiseTopOverlays();
305
- }
306
- workbenchView.setBounds(bounds);
307
- }
308
- function detachWorkbench() {
309
- if (workbenchThemeSyncBound) {
310
- nativeTheme.removeListener('updated', pushWorkbenchTheme);
311
- workbenchThemeSyncBound = false;
312
- }
313
- destroyViewInternal(workbenchView);
314
- workbenchView = null;
315
- workbenchViewAdded = false;
316
- }
317
- // Build the `file:///workspace/<rel>` URI string with each path SEGMENT
318
- // percent-encoded. A raw `rel` passed to `vscode.Uri.parse` mis-parses a
319
- // filename containing `#` (treated as a fragment) or `?` (treated as a
320
- // query), opening the wrong document; encoding each segment (but not the
321
- // `/` separators) keeps the path structure while escaping the reserved
322
- // characters. Leading slashes are already stripped by the caller.
323
- function workspaceUriFor(rel) {
324
- const encoded = rel.split('/').map(encodeURIComponent).join('/');
325
- return `file:///workspace/${encoded}`;
326
- }
327
- // Single attempt to reveal `uri` at the 0-based position in the workbench.
328
- // Resolves false when `__WB_PROBE` is not yet exposed (the workbench's
329
- // configuration service has not initialized) OR the open throws, so the
330
- // caller can retry; true once the document is shown.
331
- function tryRevealInWorkbench(uri, zeroLine, zeroCol) {
332
- if (!workbenchView || workbenchView.webContents.isDestroyed()) {
333
- return Promise.resolve(false);
334
- }
335
- const script = `(async () => {
336
- try {
337
- const P = window.__WB_PROBE; if (!P) return false
338
- const vscode = P.vscode
339
- const uri = vscode.Uri.parse(${JSON.stringify(uri)})
340
- const doc = await vscode.workspace.openTextDocument(uri)
341
- const pos = new vscode.Position(${zeroLine}, ${zeroCol})
342
- await vscode.window.showTextDocument(doc, { selection: new vscode.Range(pos, pos) })
343
- return true
344
- } catch (e) { return false }
345
- })()`;
346
- return workbenchView.webContents
347
- .executeJavaScript(script, true)
348
- .then((ok) => ok === true)
349
- .catch(() => false);
350
- }
351
- // Reveal a project file in the embedded workbench, awaiting the real open
352
- // result and retrying while the workbench finishes booting. The right-panel
353
- // console redirect (`onOpenUrl`) fires open-in-editor clicks that can land
354
- // during the workbench's lazy attach/boot window, when `__WB_PROBE` is not
355
- // yet exposed; without the retry the click is silently dropped (the inner
356
- // script returns false and the old code ignored it). Returns true only once
357
- // the document is actually shown; false when there is no workbench view or
358
- // every attempt failed.
359
- function openFileInWorkbench(relPath, line, column) {
360
- if (!workbenchView || workbenchView.webContents.isDestroyed())
361
- return false;
362
- // The workbench mirrors the active project under file:///workspace/<rel>; the
363
- // open-in-editor target is 1-based (editor convention) while vscode.Position
364
- // is 0-based, so clamp-convert. Drive the workbench's own vscode API rather
365
- // than a preload bridge — the bundle is a plain isolated http document.
366
- const uri = workspaceUriFor(relPath.replace(/^\/+/, ''));
367
- const zeroLine = Math.max(0, Math.floor(line) - 1);
368
- const zeroCol = Math.max(0, Math.floor(column) - 1);
369
- void (async () => {
370
- // Poll for workbench readiness: ~10 attempts × 150ms ≈ 1.5s, covering the
371
- // first lazy attach + ext-host boot. Each attempt re-checks the live view
372
- // so a teardown mid-retry bails cleanly.
373
- for (let attempt = 0; attempt < 10; attempt++) {
374
- if (!workbenchView || workbenchView.webContents.isDestroyed())
375
- return;
376
- if (await tryRevealInWorkbench(uri, zeroLine, zeroCol))
377
- return;
378
- await new Promise((resolve) => setTimeout(resolve, 150));
379
- }
380
- console.error('[workbench] openFileInWorkbench: workbench never became ready for', uri);
381
- })();
382
- return true;
383
- }
384
- // ── Host-controllable toolbar WebContentsView ───────────────────────────
385
- // The toolbar's webContents lifecycle belongs to the HOST, which may close
386
- // it out from under us (the documented rebuild path). In real Electron a
387
- // WebContentsView whose webContents was destroyed can report `webContents`
388
- // as undefined — not merely a destroyed handle — so every access must
389
- // tolerate BOTH (observed in the R1 e2e: `.isDestroyed()` on undefined threw
390
- // inside the control surface after the host closed the wc).
391
- function liveHostToolbarWebContents() {
392
- const wc = hostToolbarView?.webContents;
393
- if (!wc || wc.isDestroyed())
394
- return null;
395
- return wc;
396
- }
397
- // Lazily create the host-toolbar view. Mirrors `showSettings` for the
398
- // webPreferences shape and the native simulator for nav hardening +
399
- // background color (the host may load arbitrary URLs / content). Idempotent.
400
- function ensureHostToolbarView() {
401
- if (hostToolbarView && liveHostToolbarWebContents()) {
402
- return hostToolbarView;
403
- }
404
- // Rebuilding after the host destroyed the underlying webContents: detach the
405
- // dead view from the contentView and reset the added-flag so the new view
406
- // gets re-mounted (otherwise the `hostToolbarViewAdded` guard would skip the
407
- // addChildView and the toolbar would silently disappear).
408
- if (hostToolbarView && hostToolbarViewAdded) {
409
- try {
410
- ctx.windows.mainWindow.contentView.removeChildView(hostToolbarView);
411
- }
412
- catch { /* already removed */ }
413
- hostToolbarViewAdded = false;
414
- }
415
- // The framework's height-advertiser runtime is SESSION-resident: register
416
- // it on session.defaultSession (ref-counted across coexisting managers)
417
- // BEFORE the view exists, so the very first load already runs it. The
418
- // toolbar WCV stays on the defaultSession (no partition/session override)
419
- // — moving it onto its own partition would silently detach it from this
420
- // registration and height advertising would die with no error.
421
- if (!hostToolbarRuntimeAcquired) {
422
- acquireHostToolbarSessionRuntime();
423
- hostToolbarRuntimeAcquired = true;
424
- }
425
- // `webPreferences.preload` is the HOST's alone (setPreloadPath); the
426
- // built-in advertiser no longer rides it (it would execute twice — the
427
- // session copy + the webPreferences copy). The additionalArguments marker
428
- // is what the session runtime's guard keys on to activate here and stay a
429
- // zero-footprint no-op in every other defaultSession renderer.
430
- const webPreferences = {
431
- nodeIntegration: false,
432
- contextIsolation: true,
433
- sandbox: false,
434
- additionalArguments: [HOST_TOOLBAR_RUNTIME_MARKER],
435
- };
436
- if (hostToolbarPreloadOverride !== null) {
437
- webPreferences.preload = hostToolbarPreloadOverride;
438
- }
439
- const view = new WebContentsView({ webPreferences });
440
- hostToolbarView = view;
441
- // Hook the per-load MessagePort handshake (did-finish-load) + dead-port
442
- // cleanup (destroyed) on the fresh wc. AFTER the assignment above so the
443
- // channel's isCurrent guard sees this wc as the live one.
444
- hostToolbarPort.attach(view.webContents);
445
- // Paint the surface a neutral color so growing the reserved strip never
446
- // flashes white before the host content paints (mirrors the native
447
- // simulator's setBackgroundColor anti-flash).
448
- try {
449
- view.setBackgroundColor('#121212');
450
- }
451
- catch { /* stub may lack it */ }
452
- // The host may load arbitrary URLs; route popups + cross-origin in-place
453
- // navigation to the OS browser (mirror the native simulator hardening).
454
- try {
455
- view.webContents.setWindowOpenHandler(({ url }) => handleWindowOpenExternal(url));
456
- }
457
- catch { /* stub may lack it */ }
458
- return view;
459
- }
460
- function setHostToolbarBounds(bounds) {
461
- if (ctx.windows.mainWindow.isDestroyed())
462
- return;
463
- // Zero-area rect means "hide" — remove the child view but keep its
464
- // WebContents (and the host's loaded content) alive. Do NOT create the
465
- // view just to immediately hide it.
466
- if (isHidden(bounds)) {
467
- if (hostToolbarView && hostToolbarViewAdded && liveHostToolbarWebContents()) {
468
- try {
469
- ctx.windows.mainWindow.contentView.removeChildView(hostToolbarView);
470
- }
471
- catch { /* already removed */ }
472
- }
473
- hostToolbarViewAdded = false;
474
- return;
475
- }
476
- const view = ensureHostToolbarView();
477
- if (!hostToolbarViewAdded) {
478
- // addChildView appends = topmost z-order, which is correct for a strip
479
- // that sits above the devtools header.
480
- ctx.windows.mainWindow.contentView.addChildView(view);
481
- hostToolbarViewAdded = true;
482
- }
483
- view.setBounds(bounds);
484
- }
485
- // Single funnel for the height notify: retain-then-push, so the retained
486
- // value is exactly the last value the renderer was told. Every height
487
- // notify site MUST go through here — the renderer pulls the retained value
488
- // on project-view mount to replay a push it missed (the toolbar's
489
- // size-advertiser deduplicates and never re-reports).
490
- function notifyHostToolbarHeight(height) {
491
- hostToolbarLastHeight = height;
492
- ctx.notify.hostToolbarHeightChanged(height);
493
- if (settingsViewAdded)
494
- applySettingsBounds();
495
- if (popoverView)
496
- applyPopoverBounds();
497
- }
498
- function setHostToolbarHeight(extent) {
499
- // While the host pins a fixed height, drop advertiser reports entirely —
500
- // the session-resident advertiser is always installed, so forwarding its
501
- // reports would make the strip oscillate between the pinned and measured
502
- // heights on every content resize. Dropped reports must not touch the
503
- // retained value either: retention records what was NOTIFIED, not what
504
- // was reported.
505
- if (hostToolbarHeightMode !== 'auto')
506
- return;
507
- // Push the reserved height back to the main-window renderer so its
508
- // placeholder div resizes (closing the dynamic-height loop). The notified
509
- // height IS retained in main (`getHostToolbarHeight`) so a renderer that
510
- // mounts later can pull/replay it; the renderer placeholder remains the
511
- // geometry authority — the forward anchor re-reports bounds from it.
512
- notifyHostToolbarHeight(extent);
513
- }
514
- function hideHostToolbar() {
515
- if (hostToolbarView && hostToolbarViewAdded && !ctx.windows.mainWindow.isDestroyed()) {
516
- try {
517
- ctx.windows.mainWindow.contentView.removeChildView(hostToolbarView);
518
- }
519
- catch { /* already removed */ }
520
- }
521
- hostToolbarViewAdded = false;
522
- // Collapse the renderer placeholder to 0 too. Otherwise its anchor keeps a
523
- // non-zero reserved height and re-publishes bounds on the next window
524
- // resize, silently re-adding the view we just hid (unstable hide). Zeroing
525
- // the height flips the anchor to `present:false` so it stops re-publishing.
526
- // Through the funnel so the retained value follows to 0 — a renderer
527
- // mounting after the hide must replay 0, not the stale pre-hide height.
528
- notifyHostToolbarHeight(0);
529
- }
530
- const hostToolbar = {
531
- async loadURL(url) {
532
- const view = ensureHostToolbarView();
533
- // Invalidate SYNCHRONOUSLY at initiation, before the load is issued:
534
- // the current document is about to be replaced, so a same-tick send()
535
- // must report false instead of confirming delivery into it. The channel
536
- // recovers on the new document's did-finish-load handshake. (Cannot
537
- // rely on did-start-navigation here — that only covers page-initiated
538
- // navigations once the load is actually under way.)
539
- hostToolbarPort.invalidate();
540
- await view.webContents.loadURL(url);
541
- },
542
- async loadFile(filePath) {
543
- const view = ensureHostToolbarView();
544
- // Same initiation-invalidates contract as loadURL above.
545
- hostToolbarPort.invalidate();
546
- await view.webContents.loadFile(filePath);
547
- },
548
- get webContents() {
549
- return liveHostToolbarWebContents();
550
- },
551
- hide() {
552
- hideHostToolbar();
553
- },
554
- setPreloadPath(path) {
555
- // The HOST's own webPreferences.preload, applied when the view is next
556
- // (re)created. `null` = no host preload. The framework advertiser is
557
- // session-resident and unaffected either way (see ensureHostToolbarView).
558
- hostToolbarPreloadOverride = path;
559
- },
560
- setHeightMode(mode) {
561
- // Validate BEFORE touching any state: a poisoned `{ fixed }` (NaN /
562
- // ±Infinity / negative) must neither reach the renderer placeholder
563
- // (`height: NaNpx` corrupts the strip with no error anywhere) nor
564
- // clobber the standing mode — fail-closed, not fail-corrupt.
565
- if (mode !== 'auto' && !(Number.isFinite(mode.fixed) && mode.fixed >= 0)) {
566
- throw new TypeError(`hostToolbar.setHeightMode: fixed height must be a finite, non-negative number (got ${mode.fixed})`);
567
- }
568
- hostToolbarHeightMode = mode;
569
- if (mode !== 'auto') {
570
- // Pin immediately: a preload-less/static toolbar never advertises, so
571
- // waiting for the next report would leave the strip at height 0.
572
- notifyHostToolbarHeight(mode.fixed);
573
- }
574
- // Switching back to 'auto' deliberately does NOT synthesize a notify —
575
- // replaying a stale cached height would flash the old size; the NEXT
576
- // advertiser report drives the placeholder again. The RETAINED value
577
- // survives the switch though: a freshly-mounting renderer still needs
578
- // the pinned height until that next report lands.
579
- },
580
- onMessage(channel, handler) {
581
- return hostToolbarPort.onMessage(channel, handler);
582
- },
583
- onReady(handler) {
584
- return hostToolbarPort.onReady(handler);
585
- },
586
- send(channel, payload) {
587
- return hostToolbarPort.send(channel, payload);
588
- },
589
- };
590
- function overlayHeaderHeight() {
591
- return HEADER_H + hostToolbarLastHeight;
592
- }
593
- function applySettingsBounds() {
594
- if (!settingsView || ctx.windows.mainWindow.isDestroyed())
595
- return;
596
- const [w = 0, h = 0] = ctx.windows.mainWindow.getContentSize();
597
- settingsView.setBounds(layout.computeSettingsBounds(w, h, overlayHeaderHeight()));
598
- }
599
- function applyPopoverBounds() {
600
- if (!popoverView || ctx.windows.mainWindow.isDestroyed())
601
- return;
602
- const [w = 0, h = 0] = ctx.windows.mainWindow.getContentSize();
603
- popoverView.setBounds(layout.computePopoverBounds(w, h, overlayHeaderHeight()));
604
- }
605
- function clearNativeDevtoolsRetry() {
606
- nativeDevtoolsRetryToken++;
607
- if (nativeDevtoolsRetryTimer) {
608
- clearTimeout(nativeDevtoolsRetryTimer);
609
- nativeDevtoolsRetryTimer = null;
610
- }
611
- }
612
- function closeNativeDevtoolsSource() {
613
- const source = nativeDevtoolsSourceWc;
614
- nativeDevtoolsSourceWc = null;
615
- try {
616
- stopServiceConsole?.();
617
- }
618
- catch { /* already stopped */ }
619
- stopServiceConsole = null;
620
- if (!source || source.isDestroyed())
621
- return;
622
- try {
623
- if (source.isDevToolsOpened()) {
624
- source.closeDevTools();
625
- }
626
- }
627
- catch { /* source may be mid-destroy */ }
628
- }
629
- function stopFollowingNativeServiceHost() {
630
- clearNativeDevtoolsRetry();
631
- if (unsubscribeNativeRenderEvents) {
632
- try {
633
- unsubscribeNativeRenderEvents();
634
- }
635
- catch { /* ignore */ }
636
- unsubscribeNativeRenderEvents = null;
637
- }
638
- closeNativeDevtoolsSource();
639
- }
640
- // ── Open-in-editor: click a console file link → workbench ──────────────
641
- // The right-panel console is the embedded Chromium DevTools front-end. Once a
642
- // sourcemap maps a console frame back to source (restored by the service-host
643
- // importScripts sourcemap rewrite), we redirect a source-link click to OUR
644
- // workbench editor instead of the DevTools Sources panel: install an "open
645
- // resource handler" in the front-end realm that encodes (url, line, col) into
646
- // a sentinel URL and asks the front-end to open it; Electron surfaces that as
647
- // `devtools-open-url` on the inspected (service-host) wc, which we decode,
648
- // map to a project-relative path, and reveal in the workbench WCV.
649
- const openInEditorWiredWcIds = new Set();
650
- function injectOpenResourceHandler(serviceWc, devtoolsWc) {
651
- // Inject the front-end glue that routes a project source-link click to our
652
- // Monaco editor instead of the DevTools Sources panel: a capture-phase click
653
- // interceptor re-emits an encoded sentinel via
654
- // `InspectorFrontendHost.openInNewTab` → Electron `devtools-open-url`. (The
655
- // legacy `setOpenResourceHandler` hook this used to rely on is gone in
656
- // current Chromium, so the script keeps it only as a fallback.)
657
- //
658
- // Destroy-race chokepoint: this runs from a deferred `dom-ready` callback
659
- // (see wireOpenInEditor), by which time the inspected service-host wc may
660
- // already be torn down (pool swap / project close / relaunch). Reading
661
- // `serviceWc.getURL()` on a destroyed wc throws "Object has been destroyed"
662
- // synchronously, which would escape the event listener. Guard both wc and
663
- // wrap the body so a teardown mid-call degrades silently (mirrors
664
- // customizeDevtoolsTabs).
665
- if (serviceWc.isDestroyed() || devtoolsWc.isDestroyed())
666
- return;
667
- try {
668
- const sourceContext = projectSourceContextFromServiceHostUrl(serviceWc.getURL(), ctx.workspace?.getProjectPath?.());
669
- if (!sourceContext)
670
- return;
671
- devtoolsWc.executeJavaScript(buildDevtoolsProjectSourceLinksScript(sourceContext)).catch(() => { });
672
- }
673
- catch { /* wc torn down mid-call — degrade silently */ }
674
- }
675
- // ── DevTools tab customization: keep only Elements/Console/Network ──────────
676
- // Inject into the same DevTools front-end host wc that the console/network
677
- // injectors target. Reorders Elements/Console/Network to the front and closes
678
- // every other panel tab by driving the front-end's UI.ViewManager /
679
- // InspectorView.tabbedLocation (see ./devtools-tabs.ts). Best-effort: the
680
- // injected script bounded-polls for the lazily-registered panels, wraps
681
- // everything in try/catch, and silently no-ops if the API never appears —
682
- // DevTools default tab behaviour is preserved on any failure. Re-injected on
683
- // every (re)point so a re-`openDevTools` (service-host pool swap) re-applies.
684
- function customizeDevtoolsTabs(devtoolsWc) {
685
- try {
686
- if (devtoolsWc.isDestroyed())
687
- return;
688
- const inject = () => {
689
- if (devtoolsWc.isDestroyed())
690
- return;
691
- try {
692
- void devtoolsWc.executeJavaScript(buildCustomizeTabsScript()).catch(() => { });
693
- }
694
- catch { /* wc torn down mid-call */ }
695
- };
696
- if (devtoolsWc.isLoading()) {
697
- devtoolsWc.once('dom-ready', inject);
698
- }
699
- else {
700
- inject();
701
- }
702
- }
703
- catch { /* wc surface incomplete / torn down; degrade silently */ }
704
- }
705
- function wireOpenInEditor(serviceWc, devtoolsWc) {
706
- // Inject the front-end handler on every (re)attach — the DevTools front-end
707
- // wc is recreated per simulatorView, so a fresh host needs the handler set.
708
- if (!devtoolsWc.isDestroyed()) {
709
- if (devtoolsWc.isLoading()) {
710
- devtoolsWc.once('dom-ready', () => injectOpenResourceHandler(serviceWc, devtoolsWc));
711
- }
712
- else {
713
- injectOpenResourceHandler(serviceWc, devtoolsWc);
714
- }
715
- }
716
- // Attach the `devtools-open-url` decoder to the inspected service wc once
717
- // (the listener is keyed off the encoded sentinel scheme, so it only acts on
718
- // OUR redirected links; any other devtools "open in new tab" falls through).
719
- if (openInEditorWiredWcIds.has(serviceWc.id))
720
- return;
721
- openInEditorWiredWcIds.add(serviceWc.id);
722
- const onOpenUrl = (_event, url) => {
723
- const req = decodeOpenInEditorUrl(url);
724
- if (!req)
725
- return; // not our sentinel — leave it to Electron's default path
726
- const target = resolveProjectEditorTarget(serviceWc.getURL(), ctx.workspace?.getProjectPath?.(), req);
727
- if (!target)
728
- return;
729
- // Drive the workbench WCV (the sole editor) when it is attached, AND
730
- // always emit `editor:openFile` — it carries the editor-agnostic mapping
731
- // (project-relative path + 1-based line) that downstream/consumers (and the
732
- // open-in-editor contract test) observe; with Monaco gone it has no
733
- // renderer subscriber, so emitting it is harmless when the workbench
734
- // handles the actual reveal.
735
- openFileInWorkbench(target.path, target.line ?? 1, target.column ?? 1);
736
- ctx.notify.editorOpenFile(target);
737
- };
738
- serviceWc.on('devtools-open-url', onOpenUrl);
739
- // Consolidate teardown onto the connection layer (foundation.md §4 / P2),
740
- // but as a wc-LIFETIME resource: the open-in-editor wiring inspects this
741
- // service-host wc and must SURVIVE pool reuse (`reset`) — the dedup set
742
- // entry + listener stay valid across sessions reusing the same wc, and the
743
- // early-return dedup above correctly skips re-wiring after a reset. So we
744
- // register on `'closed'` (fires only on real wc destroy), NOT `own()`
745
- // (which also fires on `reset` and would leave the wc un-wired after reuse
746
- // because re-pointing the same wc.id early-returns). acquire() is
747
- // idempotent — bridge-router owning session-scoped resources on the same
748
- // serviceWc connection coexists cleanly.
749
- const conn = ctx.connections.acquire(serviceWc);
750
- conn.on('closed', () => {
751
- openInEditorWiredWcIds.delete(serviceWc.id);
752
- try {
753
- serviceWc.removeListener('devtools-open-url', onOpenUrl);
754
- }
755
- catch { /* wc gone */ }
756
- });
757
- }
758
- // Point the right-panel Chrome DevTools front-end at `next` — the SERVICE HOST
759
- // webContents (logic layer). Idempotent: if we already inspect this wc, no-op.
760
- function pointNativeDevtoolsAtServiceWc(next) {
761
- if (!simulatorView || simulatorView.webContents.isDestroyed())
762
- return true;
763
- if (nativeDevtoolsSourceWc?.id === next.id && !nativeDevtoolsSourceWc.isDestroyed()) {
764
- return true;
765
- }
766
- closeNativeDevtoolsSource();
767
- nativeDevtoolsSourceWc = next;
768
- try {
769
- next.setDevToolsWebContents(simulatorView.webContents);
770
- // DevTools renders into the right-panel host view (simulatorView); with a
771
- // custom host the `mode` is overridden, and `activate:false` prevents it
772
- // stealing focus — this re-points whenever the service window is swapped,
773
- // so a focusing window would yank focus repeatedly (disrupting the user /
774
- // e2e).
775
- next.openDevTools({ mode: 'detach', activate: false });
776
- // Redirect console source-link clicks to the workbench editor.
777
- wireOpenInEditor(next, simulatorView.webContents);
778
- // Keep only Elements/Console/Network tabs (front-most, in that order).
779
- // Re-applied on every re-point so a service-host pool swap (fresh
780
- // openDevTools) re-asserts the custom tab bar.
781
- customizeDevtoolsTabs(simulatorView.webContents);
782
- // Capture service-layer console via CDP (NOT a preload monkeypatch, which
783
- // would clobber native source attribution) and feed it to the console
784
- // fan-out (automation `App.logAdded`). Bound to THIS service wc; replaced
785
- // on the next re-point via closeNativeDevtoolsSource.
786
- try {
787
- stopServiceConsole?.();
788
- }
789
- catch { /* already stopped */ }
790
- stopServiceConsole = installServiceConsoleForward({
791
- serviceWc: next,
792
- connections: ctx.connections,
793
- emit: (entry) => ctx.consoleForwarder?.emit(entry),
794
- }).stop;
795
- return true;
796
- }
797
- catch {
798
- if (nativeDevtoolsSourceWc?.id === next.id) {
799
- nativeDevtoolsSourceWc = null;
800
- }
801
- return false;
802
- }
803
- }
804
- // Resolve the SERVICE HOST webContents for the active (or named) app. This is
805
- // the hidden service BrowserWindow's wc — a top-level wc that CAN host a
806
- // Chrome DevTools front-end (unlike a `<webview>` guest's). Re-resolved fresh
807
- // so a pre-warm-pool swap on respawn is tolerated.
808
- function pointNativeDevtoolsAtActiveServiceHost(appId) {
809
- if (!ctx.bridge?.isNativeHost())
810
- return true;
811
- if (!simulatorView || simulatorView.webContents.isDestroyed())
812
- return true;
813
- const wc = ctx.bridge.getServiceWc(appId);
814
- if (!wc || wc.isDestroyed())
815
- return false;
816
- return pointNativeDevtoolsAtServiceWc(wc);
817
- }
818
- function scheduleNativeDevtoolsFollow(appId, attempt = 0) {
819
- if (attempt >= 20)
820
- return;
821
- if (!ctx.bridge?.isNativeHost())
822
- return;
823
- const token = nativeDevtoolsRetryToken;
824
- if (nativeDevtoolsRetryTimer)
825
- clearTimeout(nativeDevtoolsRetryTimer);
826
- nativeDevtoolsRetryTimer = setTimeout(() => {
827
- nativeDevtoolsRetryTimer = null;
828
- if (token !== nativeDevtoolsRetryToken)
829
- return;
830
- if (pointNativeDevtoolsAtActiveServiceHost(appId))
831
- return;
832
- scheduleNativeDevtoolsFollow(appId, attempt + 1);
833
- }, 50);
834
- }
835
- function followNativeDevtoolsServiceHost(appId) {
836
- clearNativeDevtoolsRetry();
837
- if (pointNativeDevtoolsAtActiveServiceHost(appId))
838
- return;
839
- scheduleNativeDevtoolsFollow(appId);
840
- }
841
- // Render-side activity (a page DOM mounting / the visible page changing)
842
- // always follows a spawn or respawn, by which point the service window exists
843
- // (and may have been swapped by the pool). Re-resolve + re-point the DevTools
844
- // at the now-current service host on every such event.
845
- function onNativeRenderEvent(event) {
846
- if (event.kind !== 'activePage' && event.kind !== 'domReady')
847
- return;
848
- followNativeDevtoolsServiceHost(event.appId);
849
- }
850
- // ── ViewManager methods ─────────────────────────────────────────────────
851
- function attachNativeSimulatorDevtoolsHost() {
852
- stopFollowingNativeServiceHost();
853
- // Destroy old simulatorView to prevent WebContentsView leak
854
- if (simulatorView) {
855
- removeSimulatorDevtoolsView();
856
- try {
857
- if (!simulatorView.webContents.isDestroyed()) {
858
- simulatorView.webContents.close();
859
- }
860
- }
861
- catch { /* ignore */ }
862
- simulatorView = null;
863
- }
864
- simulatorView = new WebContentsView();
865
- // Default DevTools to Console panel (Chrome DevTools defaults to Elements).
866
- // The DevTools UI lives inside closed shadow roots, so a light-DOM
867
- // querySelector('[role="tab"]') cannot reach the tab bar to click it.
868
- // Instead drive the front-end's own view manager: the bundled DevTools
869
- // exposes `UI.ViewManager.instance().showView(id)` on `globalThis.UI`
870
- // once the front-end has finished bootstrapping. We poll for it and
871
- // request the `console` view, and also persist the choice via the
872
- // `panel-selectedTab` localStorage key so subsequent reloads honor it.
873
- const devtoolsWc = simulatorView.webContents;
874
- // Hand the network forwarder THIS front-end host wc — it injects the
875
- // simulator WCV's Network.* CDP events into `window.DevToolsAPI.dispatchMessage`
876
- // here so the native Network tab renders them (falls back to the service-host
877
- // console line when null / the API never appears). Cleared on detach.
878
- ctx.networkForward?.setDevtoolsHost(devtoolsWc);
879
- // Elements forwarding (production, always on for the native simulator): route
880
- // the front-end's Elements-panel CDP traffic (DOM/CSS/Overlay/DOMSnapshot/
881
- // DOMDebugger) onto the ACTIVE RENDER GUEST so the panel reflects the page's
882
- // live DOM tree instead of the service host it natively inspects. Reuses the
883
- // safe-area-attached debugger session (never detaches one it doesn't own);
884
- // Emulation.* and every other domain stay on the service-host path. Degrades
885
- // to the native service-host DOM if the front-end hook is unavailable. The
886
- // disposer is stopped on detach / host destroyed.
887
- if (ctx.bridge) {
888
- try {
889
- stopElementsForward?.();
890
- }
891
- catch { /* prior disposer already gone */ }
892
- // Capture THIS instance's disposer in the destroy handler — NOT the mutable
893
- // `stopElementsForward`. A respawn creates a new simulatorView whose old
894
- // devtools host wc is destroyed AFTER the new instance is already installed;
895
- // a handler closing over `stopElementsForward` would then dispose the CURRENT
896
- // instance (its reconcile loop never gets to install the hook, so Elements
897
- // falls back to the natively-inspected service host). Stop only this wc's own
898
- // forward, and clear the module pointer only while it still points here.
899
- const thisForward = installElementsForward({ devtoolsWc, bridge: ctx.bridge, connections: ctx.connections });
900
- stopElementsForward = thisForward;
901
- devtoolsWc.once('destroyed', () => {
902
- try {
903
- thisForward();
904
- }
905
- catch { /* already stopped */ }
906
- if (stopElementsForward === thisForward)
907
- stopElementsForward = null;
908
- });
909
- }
910
- devtoolsWc.once('dom-ready', () => {
911
- devtoolsWc.executeJavaScript(`
912
- (function() {
913
- try { localStorage.setItem('panel-selectedTab', '"console"') } catch {}
914
- let tries = 0
915
- const timer = setInterval(() => {
916
- tries++
917
- try {
918
- const UI = globalThis.UI
919
- const vm = UI && UI.ViewManager && typeof UI.ViewManager.instance === 'function'
920
- ? UI.ViewManager.instance()
921
- : null
922
- if (vm && typeof vm.showView === 'function') {
923
- vm.showView('console')
924
- clearInterval(timer)
925
- return
926
- }
927
- } catch {}
928
- if (tries > 80) clearInterval(timer)
929
- }, 50)
930
- })()
931
- `).catch(() => { });
932
- });
933
- // Anchor-only mount: the renderer's published rect is the SOLE authority.
934
- // If a non-zero rect was already published (it can land before this attach
935
- // on the project-open ordering), replay it; otherwise the view stays
936
- // unadded and unsized until the first publish arrives. No static-layout
937
- // fallback — an attach-time computed rect raced the precise anchor rect
938
- // and flashed the overlay at the wrong rectangle.
939
- if (simulatorBoundsOverride && !isHidden(simulatorBoundsOverride)) {
940
- ctx.windows.mainWindow.contentView.addChildView(simulatorView);
941
- simulatorViewAdded = true;
942
- simulatorView.setBounds(simulatorBoundsOverride);
943
- }
944
- if (ctx.bridge?.isNativeHost()) {
945
- unsubscribeNativeRenderEvents = ctx.bridge.onRenderEvent(onNativeRenderEvent);
946
- followNativeDevtoolsServiceHost();
947
- }
948
- }
949
- /**
950
- * Remove the custom-apis bridge `ipcMain.on` listener, if any. Idempotent.
951
- */
952
- function detachNativeCustomApiBridge() {
953
- if (nativeCustomApiBridgeHandler) {
954
- ipcMain.removeListener(SimulatorCustomApiBridgeChannel.Request, nativeCustomApiBridgeHandler);
955
- nativeCustomApiBridgeHandler = null;
956
- }
957
- }
958
- /**
959
- * The single teardown path for the live native-host simulator WCV, shared by
960
- * relaunch (attachNativeSimulator replacing the view) and project close
961
- * (detachSimulator). Detaches the view from the window, then synchronously
962
- * clears its bridge sessions (render guests + service host + mappings) BEFORE
963
- * the WCV's own async close(), so the next project never re-resolves,
964
- * re-renders, or screenshots the outgoing guest. The sync prefix clears the
965
- * maps immediately; the async tail (pool / resource-server release) is observed
966
- * so a rejection is logged rather than swallowed. The simulatorWc 'destroyed'
967
- * hook in bridge-router stays as an idempotent fallback. No-op when no view is
968
- * live; idempotent (disposeSessionsForSimulator early-returns once a session is
969
- * gone), so the eager teardown here and the 'destroyed' fallback never
970
- * double-dispose. `label` only tags the diagnostic on the async-tail failure.
971
- */
972
- function tearDownNativeSimulatorView(label) {
973
- if (!nativeSimulatorView)
974
- return;
975
- if (nativeSimulatorViewAdded && !ctx.windows.mainWindow.isDestroyed()) {
976
- try {
977
- ctx.windows.mainWindow.contentView.removeChildView(nativeSimulatorView);
978
- }
979
- catch { /* already removed */ }
980
- }
981
- try {
982
- if (!nativeSimulatorView.webContents.isDestroyed()) {
983
- ctx.bridge?.disposeSessionsForSimulator?.(nativeSimulatorView.webContents.id)
984
- ?.catch((err) => console.warn(`[view-manager] dispose sessions (${label}) failed:`, err));
985
- nativeSimulatorView.webContents.close();
986
- }
987
- }
988
- catch { /* ignore */ }
989
- nativeSimulatorView = null;
990
- nativeSimulatorViewAdded = false;
991
- }
992
- /**
993
- * NATIVE-HOST ONLY. The native simulator is a top-level WebContentsView with
994
- * NO embedder renderer, so the simulator-side `__diminaCustomApis` bridge
995
- * (`src/preload/runtime/custom-apis.ts`) cannot reach the host via
996
- * `ipcRenderer.sendToHost` — that only delivers to a `<webview>`'s embedder,
997
- * which is how the default path's `useCustomApiProxy` answered it. A
998
- * top-level WebContentsView has no embedder, and `sendToHost` does NOT loop
999
- * back as `ipc-message-host` on itself, so that channel never fires here.
1000
- *
1001
- * Under native-host the bridge instead talks to `ipcMain` directly (the same
1002
- * way `installNativeHostBridge` issues SPAWN/PAGE_OPEN): the preload sends
1003
- * `SimulatorCustomApiBridgeChannel.Request` via `ipcRenderer.send`, this
1004
- * `ipcMain.on` listener answers it. We do NOT route through `IpcRegistry` /
1005
- * the sender-policy white-list (the simulator is deliberately kept off it);
1006
- * instead we accept the message ONLY when `event.sender` is THIS precise
1007
- * simWc — the same trust model bridge-router uses for the simulator's own
1008
- * SPAWN messages. The result is dispatched through the shared
1009
- * `ctx.simulatorApis` registry and the id-correlated `Response` is posted
1010
- * back via `simWc.send`, which the preload's `ipcRenderer.on(Response)`
1011
- * listener settles.
1012
- */
1013
- function attachNativeCustomApiBridge(simWc) {
1014
- // Re-attach defensively: a stale listener from a prior simWc must never
1015
- // linger. (attachNativeSimulator already tears the old view down first.)
1016
- detachNativeCustomApiBridge();
1017
- const apis = ctx.simulatorApis;
1018
- if (!apis)
1019
- return;
1020
- const simWcId = simWc.id;
1021
- const handler = (event, req) => {
1022
- // Trust gate: only the exact native simulator webContents may drive this.
1023
- if (event.sender.id !== simWcId)
1024
- return;
1025
- const r = req;
1026
- if (!r || typeof r.id !== 'number')
1027
- return;
1028
- void handleCustomApiBridgeRequest(apis, r).then((response) => {
1029
- if (simWc.isDestroyed())
1030
- return;
1031
- simWc.send(SimulatorCustomApiBridgeChannel.Response, response);
1032
- }).catch(() => { });
1033
- };
1034
- nativeCustomApiBridgeHandler = handler;
1035
- ipcMain.on(SimulatorCustomApiBridgeChannel.Request, handler);
1036
- // Consolidate this per-webContents teardown onto the connection layer
1037
- // (foundation.md §4 / P1 DoD #3): acquiring the simWc connection makes the
1038
- // registry track this trusted webContents, and `own()`-ing the detach ties
1039
- // the ipcMain listener's lifetime to the connection so it's torn down
1040
- // deterministically when the wc is destroyed (or the connection is reset),
1041
- // instead of a bespoke `once('destroyed')`. The detach stays idempotent, so
1042
- // the defensive re-attach path calling it directly remains safe.
1043
- ctx.connections.acquire(simWc).own(detachNativeCustomApiBridge);
1044
- }
1045
- function attachNativeSimulator(simulatorUrl, _simWidth) {
1046
- if (!ctx.preloadPath) {
1047
- console.error('[workbench] attachNativeSimulator — preloadPath unset; cannot mount native simulator');
1048
- return Promise.resolve();
1049
- }
1050
- // Unblock a superseded IPC invocation. Its renderer effect cleanup marks
1051
- // that generation cancelled, so this cannot schedule a stale capture.
1052
- settleNativeSimulatorReady?.();
1053
- settleNativeSimulatorReady = null;
1054
- // Tear down any previous native simulator view (relaunch / re-open) through
1055
- // the shared single teardown path. detachNativeCustomApiBridge stays here
1056
- // (the bridge dispatcher is re-installed per attach below).
1057
- if (nativeSimulatorView) {
1058
- detachNativeCustomApiBridge();
1059
- tearDownNativeSimulatorView('relaunch');
1060
- }
1061
- nativeSimulatorProjectPath = null;
1062
- const ready = new Promise((resolve) => {
1063
- settleNativeSimulatorReady = resolve;
1064
- });
1065
- // Derive THIS project's session partition from the simulator URL's appId so
1066
- // its cookies/localStorage/cache are isolated from every other project (P0
1067
- // debt). Same project → same partition (storage survives a relaunch);
1068
- // unknown appId → the shared fallback. Configure the partition's session
1069
- // (protocol handlers + CORS/referer policy) before any project content loads
1070
- // on it — idempotent per partition.
1071
- const route = parseRoute(simulatorUrl);
1072
- // Include the project path so two projects that declare the same appId at
1073
- // different paths get isolated partitions. The service-host window for THIS
1074
- // project derives its partition from the same (appId, projectPath) pair so
1075
- // render guests + service host still share one session.
1076
- const partition = miniappPartition(route?.appId, ctx.workspace?.getProjectPath());
1077
- configureMiniappSession(partition);
1078
- // The simulator preload is a CJS bundle; webPreferences.preload obeys the
1079
- // `.js` + "type":"module" ESM rule (require would be undefined), so hand the
1080
- // top-level WebContentsView the `.cjs` sibling. contextIsolation:false +
1081
- // sandbox:false + webviewTag:true mirror what the default `<webview>` guest
1082
- // runs with, and the per-project `persist:miniapp-<key>` partition shares
1083
- // storage + the session-registered preload/CORS rules with the rest of THIS
1084
- // project's simulator (render guests + service host), never other projects'.
1085
- const view = new WebContentsView({
1086
- webPreferences: {
1087
- nodeIntegration: false,
1088
- contextIsolation: false,
1089
- sandbox: false,
1090
- webviewTag: true,
1091
- preload: cjsSiblingPreloadPath(ctx.preloadPath),
1092
- partition,
1093
- },
1094
- });
1095
- nativeSimulatorView = view;
1096
- nativeSimulatorProjectPath = ctx.workspace?.getProjectPath() || null;
1097
- // Paint the WCV surface the themed desk color (simDeskBg(): dark #121212 /
1098
- // light #e8e8e8) so a height-resize that grows the region never flashes a
1099
- // mismatched strip before DeviceShell's desk repaints — the WCV, the desk,
1100
- // and the renderer placeholder behind it are all the same color.
1101
- view.setBackgroundColor(simDeskBg());
1102
- const simWc = view.webContents;
1103
- // Keep the WCV surface in sync with the active color scheme. The
1104
- // process-wide installThemeBackgroundSync() re-syncs BrowserWindows on a
1105
- // theme switch, but this top-level WebContentsView is not a window, so its
1106
- // creation-time backgroundColor would otherwise freeze. Mirror simDeskBg()
1107
- // here on every nativeTheme `updated`; the listener is owned by the wc's
1108
- // connection so it detaches when the simulator view is torn down.
1109
- const syncDeskBg = () => {
1110
- try {
1111
- if (!simWc.isDestroyed())
1112
- view.setBackgroundColor(simDeskBg());
1113
- }
1114
- catch { /* view/wc gone */ }
1115
- };
1116
- nativeTheme.on('updated', syncDeskBg);
1117
- ctx.connections.acquire(simWc).own(() => nativeTheme.removeListener('updated', syncDeskBg));
1118
- // Service the simulator-side `__diminaCustomApis` bridge: this top-level
1119
- // WebContentsView has no embedder renderer to proxy through, so dispatch its
1120
- // `sendToHost` requests straight to `ctx.simulatorApis` from main.
1121
- attachNativeCustomApiBridge(simWc);
1122
- // DeviceShell mounts per-page render-host `<webview>`s INSIDE this view.
1123
- // Pin them onto the SAME per-project partition as their host WCV (so render
1124
- // and the rest of this project share one localStorage/cookie jar) and run
1125
- // them with contextIsolation/sandbox off so the render runtime + its preload
1126
- // share the page realm. (A top-level WebContentsView can host these guests; a
1127
- // `<webview>` guest cannot — that's the whole point of Option A.)
1128
- // Page type (`isTab`) of each attaching guest, captured from its render-host
1129
- // URL in will-attach (where `params.src` carries the full URL) and consumed
1130
- // FIFO in the matching did-attach — `guestWc.getURL()` is still empty there.
1131
- // Per-attach scope: a fresh simWc + handlers are built on every (re)attach.
1132
- const pendingGuestIsTab = [];
1133
- simWc.on('will-attach-webview', (_event, webPreferences, params) => {
1134
- ;
1135
- webPreferences.partition = partition;
1136
- params.partition = partition;
1137
- webPreferences.contextIsolation = false;
1138
- webPreferences.sandbox = false;
1139
- let isTab = false;
1140
- try {
1141
- isTab = new URL(params.src).searchParams.get('isTab') === '1';
1142
- }
1143
- catch { /* keep false */ }
1144
- pendingGuestIsTab.push(isTab);
1145
- });
1146
- simWc.on('did-attach-webview', (_event, guestWc) => {
1147
- // Scale the nested render-host page with the device zoom. The host WCV is
1148
- // sized to the SCALED bezel rect and runs at `currentZoomFactor`, so the
1149
- // guest must run at the same factor to lay the page out at the logical
1150
- // device width and paint at the right scale. At 100% (default) this is a
1151
- // no-op identity (factor 1). Newly-attached guests pick up the latest
1152
- // zoom here; live zoom changes re-apply via setNativeSimulatorViewBounds.
1153
- try {
1154
- guestWc.setZoomFactor(currentZoomFactor);
1155
- }
1156
- catch { /* guest not ready; setNativeSimulatorViewBounds re-applies */ }
1157
- // Simulate this device's CSS env(safe-area-inset-*) on the fresh guest
1158
- // before it paints, so notch-aware page layout resolves correctly. The
1159
- // bottom inset is page-type-dependent (see services/safe-area): a tab
1160
- // page's content sits above the shell tabBar (bottom 0); a non-tab page
1161
- // is full-bleed (real bottom inset). The page type was captured from the
1162
- // render-host URL in will-attach (FIFO).
1163
- const isTabGuest = pendingGuestIsTab.shift() ?? false;
1164
- safeArea.applyToGuest(guestWc, ctx.bridge?.getDevice() ?? null, isTabGuest);
1165
- guestWc.setWindowOpenHandler(({ url }) => handleWindowOpenExternal(url));
1166
- guestWc.on('will-navigate', (e, url) => {
1167
- try {
1168
- const u = new URL(url);
1169
- if (u.protocol === 'about:')
1170
- return;
1171
- if ((u.protocol === 'http:' || u.protocol === 'https:')
1172
- && (u.hostname === 'localhost' || u.hostname === '127.0.0.1')) {
1173
- return;
1174
- }
1175
- if (u.protocol === 'file:')
1176
- return;
1177
- e.preventDefault();
1178
- if (u.protocol === 'http:' || u.protocol === 'https:') {
1179
- void shell.openExternal(url);
1180
- }
1181
- }
1182
- catch {
1183
- e.preventDefault();
1184
- }
1185
- });
1186
- // The outer DeviceShell loading is insufficient: slow projects attach a
1187
- // render guest later. Resolve the renderer's attach IPC only after that
1188
- // first mini-app page has completed its own document load.
1189
- guestWc.once('did-finish-load', () => {
1190
- if (nativeSimulatorView !== view || simWc.isDestroyed())
1191
- return;
1192
- settleNativeSimulatorReady?.();
1193
- settleNativeSimulatorReady = null;
1194
- });
1195
- // A render-host guest only attaches after a spawn, so the service window
1196
- // exists by now — (re)point the right-panel DevTools at it. Belt-and-braces
1197
- // with the `onRenderEvent` path in case its emit lost the attach race.
1198
- followNativeDevtoolsServiceHost();
1199
- });
1200
- // The simulator loads http://localhost:<port>/simulator.html. Harden popups
1201
- // + in-place navigation: allow the dev server origin (and about:blank /
1202
- // file:// render hosts), route everything else to the OS browser.
1203
- simWc.setWindowOpenHandler(({ url }) => handleWindowOpenExternal(url));
1204
- simWc.on('will-navigate', (e, url) => {
1205
- try {
1206
- const u = new URL(url);
1207
- if (u.protocol === 'about:')
1208
- return;
1209
- if (u.protocol === 'file:')
1210
- return;
1211
- if ((u.protocol === 'http:' || u.protocol === 'https:')
1212
- && (u.hostname === 'localhost' || u.hostname === '127.0.0.1')) {
1213
- return;
1214
- }
1215
- e.preventDefault();
1216
- if (u.protocol === 'http:' || u.protocol === 'https:') {
1217
- void shell.openExternal(url);
1218
- }
1219
- }
1220
- catch {
1221
- e.preventDefault();
1222
- }
1223
- });
1224
- void simWc.loadURL(simulatorUrl).catch((err) => {
1225
- console.error('[workbench] attachNativeSimulator — loadURL failed', err);
1226
- });
1227
- // This WebContentsView's webContents IS the simulator: the native-host
1228
- // preload runs here and issues SPAWN, so bridge-router resolves
1229
- // ap.simulatorWc = event.sender = this wc, and SIMULATOR_EVENTS flow back.
1230
- simulatorWebContentsId = simWc.id;
1231
- // Forward this WCV's network requests (wx.request/download/upload run here,
1232
- // not in the service host) into the service-host console so they show in the
1233
- // embedded DevTools the right panel hosts. Attaches the CDP debugger; no-op
1234
- // if the forwarder is unwired (partial test ctx) or the debugger is claimed.
1235
- ctx.networkForward?.attachSimulator(simWc);
1236
- // Model A: bounds come ONLY from the renderer's measured inner-screen rect.
1237
- // If a report already landed before this attach (project-open ordering),
1238
- // replay it now — setNativeSimulatorViewBounds adds + sizes the view. Else
1239
- // the view stays unadded until the next reportBounds. No coarse fallback.
1240
- if (lastRendererRect) {
1241
- setNativeSimulatorViewBounds(lastRendererRect);
1242
- }
1243
- // Native-host page code (console.log / wx.request / Sources) runs in the
1244
- // hidden SERVICE HOST window, not this DeviceShell host nor the render-host
1245
- // guests. Keep the right-panel DevTools host view stable and point it at the
1246
- // service host so its Console/Network(fetch)/Sources reflect the logic layer.
1247
- // (The UI/view layer's Elements equivalent is the native WXML panel +
1248
- // render-guest highlight chain, which targets the active render guest.)
1249
- attachNativeSimulatorDevtoolsHost();
1250
- return ready;
1251
- }
1252
- function detachSimulator() {
1253
- settleNativeSimulatorReady?.();
1254
- settleNativeSimulatorReady = null;
1255
- stopFollowingNativeServiceHost();
1256
- detachNativeCustomApiBridge();
1257
- // Stop Elements forwarding (detaches only the debugger sessions IT attached;
1258
- // safe-area's sessions are untouched). Idempotent — the host 'destroyed'
1259
- // handler may have already run.
1260
- try {
1261
- stopElementsForward?.();
1262
- }
1263
- catch { /* already stopped */ }
1264
- stopElementsForward = null;
1265
- // Stop forwarding the simulator WCV's network (its debugger is detached as
1266
- // the view is closed below, but drop our session deterministically first).
1267
- // Also drop the DevTools front-end host — its wc is destroyed below; a stale
1268
- // ref would make the forwarder dispatch into a dead wc instead of falling
1269
- // back to the console.
1270
- ctx.networkForward?.detachSimulator();
1271
- ctx.networkForward?.setDevtoolsHost(null);
1272
- // Native-host simulator content view (no-op in the default path) — same
1273
- // single teardown path as relaunch. detachNativeCustomApiBridge was already
1274
- // called above.
1275
- tearDownNativeSimulatorView('close');
1276
- hidePopover();
1277
- // Drop the settings view too — the previous detachAllViews() did.
1278
- destroyViewInternal(settingsView);
1279
- settingsView = null;
1280
- settingsViewAdded = false;
1281
- destroyViewInternal(simulatorView);
1282
- simulatorView = null;
1283
- simulatorViewAdded = false;
1284
- simulatorWebContentsId = null;
1285
- nativeSimulatorProjectPath = null;
1286
- // Drop the renderer-published rect so a stale "hidden" override doesn't
1287
- // suppress the next view before its renderer republishes.
1288
- simulatorBoundsOverride = null;
1289
- // Also drop the cached native-simulator rect: attachNativeSimulator replays
1290
- // lastRendererRect on attach, so a leftover rect/offset from a torn-down
1291
- // session must not be replayed onto a fresh re-attach (stale slice/offset).
1292
- lastRendererRect = null;
1293
- }
1294
- // Remove (but do not destroy) the DevTools overlay from the contentView.
1295
- // Internal teardown helper for re-attach; user-facing visibility is the
1296
- // anchor 0×0 single path (`setSimulatorDevtoolsBounds`).
1297
- function removeSimulatorDevtoolsView() {
1298
- if (simulatorView && simulatorViewAdded) {
1299
- try {
1300
- ctx.windows.mainWindow.contentView.removeChildView(simulatorView);
1301
- }
1302
- catch (e) {
1303
- console.error('[workbench] removeSimulatorDevtoolsView error', e);
1304
- }
1305
- simulatorViewAdded = false;
1306
- }
1307
- }
1308
- async function showSettings() {
1309
- if (!settingsView) {
1310
- settingsView = new WebContentsView({
1311
- webPreferences: {
1312
- nodeIntegration: false,
1313
- contextIsolation: true,
1314
- sandbox: false,
1315
- preload: mainPreloadPath,
1316
- },
1317
- });
1318
- // Overlay loads mainPreloadPath, so the same navigation rules as the
1319
- // main window apply — see navigation-hardening.ts.
1320
- applyNavigationHardening(settingsView.webContents, ctx.rendererDir);
1321
- // Transparent backing: the settings view now spans the whole content area
1322
- // (computeSettingsBounds) and its renderer paints a transparent backdrop +
1323
- // an opaque right-side panel, so the underlying editor/simulator show
1324
- // through and a backdrop click closes the overlay (mirrors the popover).
1325
- settingsView.setBackgroundColor('#00000000');
1326
- await settingsView.webContents.loadFile(path.join(ctx.rendererDir, 'entries/settings/index.html'));
1327
- }
1328
- if (!settingsViewAdded) {
1329
- ctx.windows.mainWindow.contentView.addChildView(settingsView);
1330
- settingsViewAdded = true;
1331
- }
1332
- applySettingsBounds();
1333
- }
1334
- function hideSettings() {
1335
- if (settingsView && settingsViewAdded) {
1336
- try {
1337
- ctx.windows.mainWindow.contentView.removeChildView(settingsView);
1338
- }
1339
- catch { /* ignore */ }
1340
- settingsViewAdded = false;
1341
- }
1342
- }
1343
- // Keep the TOP-tier overlays (settings, popover) above the BASE-tier native
1344
- // overlays (the native simulator WCV + the console/DevTools WCV). Native
1345
- // overlays are z-ordered by `addChildView` insertion order — the last-added
1346
- // sits on top — so RE-attaching a base overlay while a top overlay is open
1347
- // (e.g. the simulator re-shows on a tab switch, or the console bounds
1348
- // republish re-adds it) would move the base ABOVE the open settings/popover
1349
- // and occlude it. Whenever a base overlay is (re)added, re-append the open top
1350
- // overlays so they stay on top. Settings is re-appended before popover so a
1351
- // simultaneously-open popover ends up topmost. A no-op when neither is open
1352
- // (pinned by the z-order guard test).
1353
- function raiseTopOverlays() {
1354
- if (ctx.windows.mainWindow.isDestroyed())
1355
- return;
1356
- const cv = ctx.windows.mainWindow.contentView;
1357
- if (settingsView && settingsViewAdded)
1358
- cv.addChildView(settingsView);
1359
- if (popoverView)
1360
- cv.addChildView(popoverView);
1361
- }
1362
- function showPopover(data) {
1363
- hidePopover();
1364
- const popover = new WebContentsView({
1365
- webPreferences: {
1366
- nodeIntegration: false,
1367
- contextIsolation: true,
1368
- sandbox: false,
1369
- preload: mainPreloadPath,
1370
- },
1371
- });
1372
- // Popover overlay loads mainPreloadPath — same navigation rules apply.
1373
- applyNavigationHardening(popover.webContents, ctx.rendererDir);
1374
- popoverView = popover;
1375
- popover.setBackgroundColor('#00000000');
1376
- ctx.windows.mainWindow.contentView.addChildView(popover);
1377
- applyPopoverBounds();
1378
- popover.webContents.once('did-finish-load', () => {
1379
- ctx.notify.popoverInit(popover, data);
1380
- });
1381
- popover.webContents.loadFile(path.join(ctx.rendererDir, 'entries/popover/index.html'));
1382
- }
1383
- function hidePopover() {
1384
- if (popoverView) {
1385
- destroyViewInternal(popoverView);
1386
- popoverView = null;
1387
- ctx.notify.popoverClosed();
1388
- }
1389
- }
1390
- function repositionAll() {
1391
- // Simulator + DevTools overlay: bounds owned by the renderer's anchor
1392
- // publishes; their ResizeObserver/window-resize listeners re-measure, so
1393
- // no static re-apply here.
1394
- if (settingsView && settingsViewAdded)
1395
- applySettingsBounds();
1396
- if (popoverView)
1397
- applyPopoverBounds();
29
+ // hostToolbar and the overlay panels form a height cycle: the toolbar height
30
+ // offsets the overlays' top edge, and a toolbar height change re-applies the
31
+ // present overlays. The toolbar receives a thunk into the (hoisted) function
32
+ // declaration below so the reference resolves at runtime — after the overlay
33
+ // panels exist — while both domains stay plain const bindings.
34
+ const hostToolbar = createHostToolbarView(ctx, reconciler, {
35
+ reapplyPresentOverlays: () => reapplyToolbarDependentOverlays(),
36
+ });
37
+ const overlayPanels = createOverlayPanelsView(ctx, reconciler, {
38
+ getHostToolbarHeight: hostToolbar.getHostToolbarHeight,
39
+ });
40
+ const nativeSimulator = createNativeSimulatorView(ctx, reconciler, {
41
+ safeArea,
42
+ devtoolsHost,
43
+ overlayPanels,
44
+ });
45
+ function reapplyToolbarDependentOverlays() {
46
+ overlayPanels.reapplyPresentOverlays();
1398
47
  }
1399
48
  function disposeAll() {
1400
- detachSimulator();
49
+ // Aggregate simulator detach first (native simulator + devtools host +
50
+ // settings/popover), then the workbench, the host toolbar, and the
51
+ // per-guest safe-area sessions.
52
+ nativeSimulator.detachSimulator();
1401
53
  // Embedded workbench editor view (no-op when the host never opted in;
1402
54
  // also removes the devtools-theme sync listener).
1403
- detachWorkbench();
1404
- // Narrow channel first: close the live MessagePort + sweep the onMessage
1405
- // registry, so a send() racing teardown reports false instead of posting
1406
- // into a wc that is about to be closed.
1407
- hostToolbarPort.dispose();
1408
- // Host-controllable toolbar view: removed from the contentView + its
1409
- // WebContents closed (the host's loaded content is torn down on app exit).
1410
- destroyViewInternal(hostToolbarView);
1411
- hostToolbarView = null;
1412
- hostToolbarViewAdded = false;
1413
- // Release this manager's reference on the shared defaultSession
1414
- // toolbar-runtime registration (only if it ever acquired one — a manager
1415
- // that never used the toolbar must not drive the shared count to zero).
1416
- // The LAST release unregisters; other coexisting managers keep theirs.
1417
- if (hostToolbarRuntimeAcquired) {
1418
- releaseHostToolbarSessionRuntime();
1419
- hostToolbarRuntimeAcquired = false;
1420
- }
55
+ workbench.detachWorkbench();
56
+ hostToolbar.dispose();
1421
57
  safeArea.dispose();
1422
58
  }
1423
- function setNativeSimulatorViewBounds(params) {
1424
- // Cache unconditionally so attachNativeSimulator can replay a report that
1425
- // landed before the view existed (project-open ordering). This rect is the
1426
- // ONLY authority for the native WCV bounds.
1427
- lastRendererRect = params;
1428
- if (ctx.windows.mainWindow.isDestroyed() || !nativeSimulatorView)
1429
- return;
1430
- const p = layout.computeNativeSimulatorViewParams(params, params.zoom);
1431
- currentZoomFactor = p.zoomFactor;
1432
- // A zero-area rect means "hide" — the renderer reports this when the
1433
- // simulator panel/cell is toggled off or unmounts (toolbar toggle only
1434
- // mutates renderer layout state, so without this the WCV would stay
1435
- // painted over its old region). Mirror `setSimulatorDevtoolsBounds`:
1436
- // remove the child view from the contentView but keep its WebContents
1437
- // alive, so re-showing doesn't re-pay the simulator bootstrap.
1438
- if (isHidden(p.bounds)) {
1439
- if (nativeSimulatorViewAdded) {
1440
- try {
1441
- ctx.windows.mainWindow.contentView.removeChildView(nativeSimulatorView);
1442
- }
1443
- catch { /* already removed */ }
1444
- nativeSimulatorViewAdded = false;
1445
- }
1446
- return;
1447
- }
1448
- if (!nativeSimulatorViewAdded) {
1449
- ctx.windows.mainWindow.contentView.addChildView(nativeSimulatorView);
1450
- nativeSimulatorViewAdded = true;
1451
- // Re-attaching this base overlay moved it to the top of the z-stack; keep
1452
- // any open settings/popover above it.
1453
- raiseTopOverlays();
1454
- }
1455
- nativeSimulatorView.setBounds(p.bounds);
1456
- const simWc = nativeSimulatorView.webContents;
1457
- if (!simWc.isDestroyed()) {
1458
- simWc.setZoomFactor(p.zoomFactor);
1459
- }
1460
- // Propagate zoom to any already-attached nested render-host guests so the
1461
- // page rescales live on zoom change (newly-attached guests get it in
1462
- // did-attach-webview). `webContents.getAllWebContents()` includes guests;
1463
- // filter to those hosted by this simulator wc.
1464
- try {
1465
- for (const wc of webContents.getAllWebContents()) {
1466
- if (wc.isDestroyed())
1467
- continue;
1468
- if (wc.hostWebContents === simWc) {
1469
- wc.setZoomFactor(p.zoomFactor);
1470
- }
1471
- }
1472
- }
1473
- catch { /* hostWebContents unavailable; guests get zoom on attach */ }
1474
- }
1475
- function resize(_simWidth) {
1476
- // Simulator + DevTools overlay bounds are owned solely by the renderer's
1477
- // anchor publishes — its ResizeObserver/window-resize listeners re-measure
1478
- // on this same resize. No static re-apply (it raced the precise rect).
1479
- if (settingsViewAdded)
1480
- applySettingsBounds();
1481
- }
1482
59
  return {
1483
- attachNativeSimulator,
1484
- detachSimulator,
60
+ attachNativeSimulator: nativeSimulator.attachNativeSimulator,
61
+ softReloadNativeSimulator: nativeSimulator.softReloadNativeSimulator,
62
+ detachSimulator: nativeSimulator.detachSimulator,
1485
63
  reapplySafeArea: (device) => safeArea.reapplyAll(device),
1486
- showSettings,
1487
- hideSettings,
1488
- showPopover,
1489
- hidePopover,
1490
- repositionAll,
64
+ showSettings: overlayPanels.showSettings,
65
+ hideSettings: overlayPanels.hideSettings,
66
+ showPopover: overlayPanels.showPopover,
67
+ hidePopover: overlayPanels.hidePopover,
68
+ repositionAll: () => overlayPanels.reapplyPresentOverlays(),
1491
69
  disposeAll,
1492
- getSimulatorWebContentsId: () => simulatorWebContentsId,
1493
- getSimulatorWebContents: () => {
1494
- if (simulatorWebContentsId == null)
1495
- return null;
1496
- const wc = webContents.fromId(simulatorWebContentsId);
1497
- return wc && !wc.isDestroyed() ? wc : null;
1498
- },
1499
- getSimulatorProjectPath: () => nativeSimulatorProjectPath,
1500
- getSettingsWebContents: () => {
1501
- if (!settingsView)
1502
- return null;
1503
- if (settingsView.webContents.isDestroyed())
1504
- return null;
1505
- return settingsView.webContents;
1506
- },
1507
- getSettingsWebContentsId: () => {
1508
- if (!settingsView)
1509
- return null;
1510
- if (settingsView.webContents.isDestroyed())
1511
- return null;
1512
- return settingsView.webContents.id;
1513
- },
1514
- getPopoverWebContentsId: () => {
1515
- if (!popoverView)
1516
- return null;
1517
- if (popoverView.webContents.isDestroyed())
1518
- return null;
1519
- return popoverView.webContents.id;
1520
- },
1521
- getHostToolbarWebContentsId: () => liveHostToolbarWebContents()?.id ?? null,
1522
- getHostToolbarHeight: () => hostToolbarLastHeight,
1523
- setNativeSimulatorViewBounds,
1524
- resize,
1525
- setSimulatorDevtoolsBounds,
1526
- attachWorkbench,
1527
- setWorkbenchSource,
1528
- setWorkbenchBounds,
1529
- detachWorkbench,
1530
- openFileInWorkbench,
1531
- setHostToolbarBounds,
1532
- setHostToolbarHeight,
1533
- hostToolbar,
70
+ getSimulatorWebContentsId: nativeSimulator.getSimulatorWebContentsId,
71
+ getSimulatorWebContents: nativeSimulator.getSimulatorWebContents,
72
+ getSimulatorProjectPath: nativeSimulator.getSimulatorProjectPath,
73
+ getSettingsWebContents: overlayPanels.getSettingsWebContents,
74
+ getSettingsWebContentsId: overlayPanels.getSettingsWebContentsId,
75
+ getPopoverWebContentsId: overlayPanels.getPopoverWebContentsId,
76
+ getHostToolbarWebContentsId: hostToolbar.getHostToolbarWebContentsId,
77
+ getHostToolbarHeight: hostToolbar.getHostToolbarHeight,
78
+ resize: () => overlayPanels.applySettingsBoundsIfPresent(),
79
+ setPlacementSnapshot: reconciler.setPlacementSnapshot,
80
+ attachWorkbench: workbench.attachWorkbench,
81
+ setWorkbenchSource: workbench.setWorkbenchSource,
82
+ detachWorkbench: workbench.detachWorkbench,
83
+ openFileInWorkbench: workbench.openFileInWorkbench,
84
+ setHostToolbarHeight: hostToolbar.setHostToolbarHeight,
85
+ hostToolbar: hostToolbar.control,
1534
86
  };
1535
87
  }
1536
88
  //# sourceMappingURL=view-manager.js.map