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

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 CHANGED
@@ -53,6 +53,13 @@ launch({
53
53
  // simulator 自定义 API:per-context
54
54
  instance.registerSimulatorApi('login', (params) => myLogin(params))
55
55
 
56
+ // simulator 下游 UI:框架管理 DeviceShell 挂载点与生命周期
57
+ const nativeUi = instance.registerSimulatorUiExtension({
58
+ id: 'my.native-ui',
59
+ rendererScriptPath: '/absolute/path/to/native-ui.js',
60
+ })
61
+ instance.registerSimulatorApi('share', params => nativeUi.invoke('share', params))
62
+
56
63
  // 自定义 IPC:经 gated 的 IpcRegistry,不再裸 ipcMain.handle
57
64
  instance.ipc.handle('my:action', () => collectStats())
58
65
 
@@ -207,6 +214,7 @@ src/
207
214
  | 方法 | 说明 |
208
215
  | --- | --- |
209
216
  | `instance.registerSimulatorApi(name, handler)` | 注册 simulator 自定义 API,小程序里 `wx.<name>()` 调用(详见下方"Simulator 自定义 API")。返回 `Disposable` |
217
+ | `instance.registerSimulatorUiExtension({ id, rendererScriptPath })` | 注册可信 renderer bundle。框架负责注入当前 simulator、稳定 Overlay Root、soft reload 的 current/pending 切换和销毁;返回可 `invoke()` 的句柄 |
210
218
  | `instance.ipc` | `IpcRegistry` 实例,`instance.ipc.handle(channel, fn)` 注册自定义 IPC;已绑定 `senderPolicy` 网关 |
211
219
  | `instance.registerTrustedWindow(win)` | 把 host 自有弹窗 `BrowserWindow` 加入受信 sender 集,否则其发起的 `instance.ipc` 调用会被网关拒绝。窗口关闭即移除 |
212
220
 
@@ -482,6 +490,66 @@ launch({
482
490
 
483
491
  ---
484
492
 
493
+ ## Simulator UI 扩展
494
+
495
+ 需要分享面板、授权弹窗等产品 UI 的下游 host,应同时使用两个正交接口:
496
+
497
+ - `registerSimulatorApi` 定义小程序侧 `wx.*` 命令。
498
+ - `registerSimulatorUiExtension` 安装下游 renderer bundle,并通过返回句柄把命令送到 UI。
499
+
500
+ 主进程只注册绝对路径指向的可信构建产物:
501
+
502
+ ```typescript
503
+ const nativeUi = instance.registerSimulatorUiExtension({
504
+ id: 'my.native-ui',
505
+ rendererScriptPath: fileURLToPath(
506
+ new URL('../renderer/native-ui.js', import.meta.url),
507
+ ),
508
+ })
509
+
510
+ instance.registerSimulatorApi('share', params =>
511
+ nativeUi.invoke('share', params),
512
+ )
513
+ ```
514
+
515
+ renderer bundle 使用 `@dimina-kit/devtools/simulator-ui` 注册实现:
516
+
517
+ ```typescript
518
+ import { registerSimulatorUiExtension } from '@dimina-kit/devtools/simulator-ui'
519
+
520
+ let runtime
521
+
522
+ registerSimulatorUiExtension({
523
+ id: 'my.native-ui',
524
+ mount({ overlayRoot, appId, signal }) {
525
+ runtime = createNativeUi({ root: overlayRoot, appId })
526
+ signal.addEventListener('abort', () => runtime.destroy(), { once: true })
527
+ return () => runtime.destroy()
528
+ },
529
+ invoke(method, params) {
530
+ return runtime.invoke(method, params)
531
+ },
532
+ onChromeAction(action) {
533
+ if (action.name !== 'capsule.more') return false
534
+ runtime.showMoreMenu(action)
535
+ return true
536
+ },
537
+ })
538
+ ```
539
+
540
+ 契约边界:
541
+
542
+ - 框架只提供设备内的稳定 Overlay Root,不接收 React Component,因而不绑定下游 React 版本。
543
+ - 只有可见的 current `DeviceShell` 持有挂载点;soft reload 提升 pending shell 时,旧 mount 先收到 abort/cleanup,再在新 root 上 mount。
544
+ - Overlay Root 默认不拦截页面点击;扩展实际创建的交互层需要设置 `pointer-events: auto`。
545
+ - 胶囊菜单通过语义动作 `capsule.more` 分发,不应查询 `.menu-capsule__more` 或拦截 DOM click。
546
+ - `id` 在一个 context 内唯一;script 必须用相同 `id` 注册 renderer 实现,否则安装失败。
547
+ - 每次 simulator 顶层文档完成加载时都会重新读取 `rendererScriptPath`,因此开发态重建
548
+ bundle 后执行硬 reload 即可加载新代码;soft reload 只切换 DeviceShell 挂载点,不重复执行 bundle。
549
+ - `dispose()` 会注销 renderer 实现;context 销毁时自动执行。`invoke()` 参数和返回值必须可跨 Electron IPC/`executeJavaScript` 序列化。
550
+
551
+ ---
552
+
485
553
  ## Host Toolbar(宿主自定义工具栏)
486
554
 
487
555
  下游通过 `instance.context.views.hostToolbar` 拥有 devtools 头部下方的工具栏条(一个 WebContentsView):`loadURL` / `loadFile` 加载自己的内容,`setPreloadPath` 注入自己的 preload,`setHeightMode` 钉死或自动跟随内容高度(自动模式要求内容自带 shrink-to-fit 的 `[data-host-toolbar-root]` 包裹元素;`{ fixed }` 校验入参——非有限数或负数同步抛 `TypeError` 且不污染既有模式)。主进程保留最后一次下发的高度(`views.getHostToolbarHeight()`),项目视图的占位条挂载时会主动拉取并回放——广播器对已上报的高度去重不再重发,没有这一步,冷启动在项目列表期间的上报、以及关闭项目再打开后的高度都会永久丢失(工具栏条塌缩为 0)。
@@ -11,6 +11,7 @@ export type { ViewManager } from './services/views/view-manager.js';
11
11
  export type { WorkspaceService } from './services/workspace/workspace-service.js';
12
12
  export type { Project, ProjectPages, ProjectSettings } from './services/projects/project-repository.js';
13
13
  export type { SimulatorApiHandler } from './services/simulator/custom-apis.js';
14
+ export type { SimulatorUiExtensionHandle, SimulatorUiExtensionRegistration, } from '../shared/simulator-ui.js';
14
15
  export { rendererDir, defaultPreloadPath, simulatorDir, getRendererDir, getPreloadDir, getRendererHtml, } from './utils/paths.js';
15
16
  export { IpcRegistry } from './utils/ipc-registry.js';
16
17
  export type { SenderPolicy } from './utils/ipc-registry.js';
@@ -1,5 +1,6 @@
1
1
  import { BrowserWindow } from 'electron';
2
2
  import type { WorkbenchAppConfig } from '../../shared/types.js';
3
+ import type { SimulatorUiExtensionHandle, SimulatorUiExtensionRegistration } from '../../shared/simulator-ui.js';
3
4
  import type { SimulatorApiHandler } from '../services/simulator/custom-apis.js';
4
5
  import { type WorkbenchContext } from '../services/workbench-context.js';
5
6
  import { type AutomationServer } from '../services/automation/index.js';
@@ -15,6 +16,8 @@ export interface WorkbenchAppInstance {
15
16
  registerTrustedWindow: (win: BrowserWindow) => Disposable;
16
17
  /** Registers a simulator custom API into this context's registry. */
17
18
  registerSimulatorApi: (name: string, handler: SimulatorApiHandler) => Disposable;
19
+ /** Registers a downstream renderer extension for the simulator device UI. */
20
+ registerSimulatorUiExtension: (registration: SimulatorUiExtensionRegistration) => SimulatorUiExtensionHandle;
18
21
  automationServer?: AutomationServer;
19
22
  updateManager?: UpdateManager;
20
23
  dispose: () => Promise<void>;
@@ -476,6 +476,14 @@ onInstanceCreated) {
476
476
  // teardown, so a single dispose leaves no dead entry behind.
477
477
  registerTrustedWindow: (win) => context.registry.add(registerTrustedWindow(context, win)),
478
478
  registerSimulatorApi: (name, handler) => context.registry.add(toDisposable(context.simulatorApis.register(name, handler))),
479
+ registerSimulatorUiExtension: (registration) => {
480
+ const extension = context.simulatorUiExtensions.register(registration);
481
+ const owned = context.registry.add(extension);
482
+ return {
483
+ dispose: () => owned.dispose(),
484
+ invoke: (method, params) => extension.invoke(method, params),
485
+ };
486
+ },
479
487
  dispose: () => disposeContext(context),
480
488
  };
481
489
  onInstanceCreated?.(instance);
@@ -5305,6 +5305,7 @@ function createNativeSimulatorView(ctx, reconciler, deps) {
5305
5305
  }
5306
5306
  function tearDownNativeSimulatorView(label) {
5307
5307
  if (!nativeSimulatorView) return;
5308
+ ctx.simulatorUiExtensions?.detach(nativeSimulatorView.webContents);
5308
5309
  if (nativeSimulatorViewAdded && !ctx.windows.mainWindow.isDestroyed()) {
5309
5310
  try {
5310
5311
  ctx.windows.mainWindow.contentView.removeChildView(nativeSimulatorView);
@@ -5381,6 +5382,7 @@ function createNativeSimulatorView(ctx, reconciler, deps) {
5381
5382
  nativeSimulatorProjectPath = ctx.workspace?.getProjectPath() || null;
5382
5383
  view.setBackgroundColor(simDeskBg());
5383
5384
  const simWc = view.webContents;
5385
+ ctx.simulatorUiExtensions?.attach(simWc);
5384
5386
  const syncDeskBg = () => {
5385
5387
  try {
5386
5388
  if (!simWc.isDestroyed()) view.setBackgroundColor(simDeskBg());
@@ -6192,6 +6194,173 @@ function createLocalProjectsProvider() {
6192
6194
  };
6193
6195
  }
6194
6196
 
6197
+ // src/main/services/simulator/ui-extensions.ts
6198
+ import { readFile as readFile2 } from "node:fs/promises";
6199
+ import { isAbsolute } from "node:path";
6200
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
6201
+ var EXTENSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
6202
+ function createSimulatorUiExtensionRegistry(options = {}) {
6203
+ const loadSource = options.loadSource ?? ((scriptPath) => readFile2(scriptPath, "utf8"));
6204
+ const entries = /* @__PURE__ */ new Map();
6205
+ let active = null;
6206
+ function assertRegistration(registration) {
6207
+ if (!EXTENSION_ID_RE.test(registration.id)) {
6208
+ throw new Error(`Invalid simulator UI extension id: "${registration.id}"`);
6209
+ }
6210
+ if (!isAbsolute(registration.rendererScriptPath)) {
6211
+ throw new Error("Simulator UI extension rendererScriptPath must be absolute");
6212
+ }
6213
+ if (entries.has(registration.id)) {
6214
+ throw new Error(`Simulator UI extension "${registration.id}" is already registered`);
6215
+ }
6216
+ }
6217
+ function activeExpression(method, args) {
6218
+ return `globalThis.__diminaSimulatorUi.${method}(${args.map((arg) => JSON.stringify(arg)).join(",")})`;
6219
+ }
6220
+ async function install(entry, target) {
6221
+ const { id, rendererScriptPath } = entry.registration;
6222
+ if (target.loaded.has(id)) return;
6223
+ const pending = target.loading.get(id);
6224
+ if (pending) return await pending;
6225
+ const generation = target.generation;
6226
+ const task = (async () => {
6227
+ const source = await loadSource(rendererScriptPath);
6228
+ if (entries.get(id) !== entry || active !== target || target.webContents.isDestroyed() || !target.ready || target.generation !== generation) {
6229
+ throw new Error("Simulator UI extension target is no longer available");
6230
+ }
6231
+ const sourceUrl = pathToFileURL2(rendererScriptPath).href;
6232
+ const wrapped = `(()=>{
6233
+ ${source}
6234
+ ;return undefined
6235
+ })()
6236
+ //# sourceURL=${sourceUrl}`;
6237
+ await target.webContents.executeJavaScript(wrapped, true);
6238
+ if (entries.get(id) !== entry || active !== target || target.webContents.isDestroyed() || !target.ready || target.generation !== generation) {
6239
+ throw new Error("Simulator UI extension target was destroyed during installation");
6240
+ }
6241
+ const registered = await target.webContents.executeJavaScript(
6242
+ `Boolean(globalThis.__diminaSimulatorUi?.has(${JSON.stringify(id)}))`,
6243
+ true
6244
+ );
6245
+ if (!registered) {
6246
+ throw new Error(
6247
+ `Simulator UI extension "${id}" did not register its renderer implementation`
6248
+ );
6249
+ }
6250
+ target.loaded.add(id);
6251
+ })().finally(() => {
6252
+ if (target.loading.get(id) === task) target.loading.delete(id);
6253
+ });
6254
+ target.loading.set(id, task);
6255
+ return await task;
6256
+ }
6257
+ async function installAll(target) {
6258
+ for (const entry of entries.values()) {
6259
+ try {
6260
+ await install(entry, target);
6261
+ } catch (error) {
6262
+ console.warn(
6263
+ `[simulator-ui] failed to install "${entry.registration.id}":`,
6264
+ error instanceof Error ? error.message : String(error)
6265
+ );
6266
+ }
6267
+ }
6268
+ }
6269
+ function uninstall(id, target = active) {
6270
+ if (!target) return;
6271
+ target.loaded.delete(id);
6272
+ target.loading.delete(id);
6273
+ if (!target.ready || target.webContents.isDestroyed()) return;
6274
+ void target.webContents.executeJavaScript(
6275
+ `globalThis.__diminaSimulatorUi?.unregister(${JSON.stringify(id)})`,
6276
+ true
6277
+ ).catch(() => {
6278
+ });
6279
+ }
6280
+ function detach(webContents4) {
6281
+ const target = active;
6282
+ if (!target || webContents4 && target.webContents !== webContents4) return;
6283
+ active = null;
6284
+ if (!target.webContents.isDestroyed()) {
6285
+ target.webContents.removeListener("did-finish-load", target.onLoaded);
6286
+ }
6287
+ target.loaded.clear();
6288
+ target.loading.clear();
6289
+ }
6290
+ function attach(webContents4) {
6291
+ detach();
6292
+ const target = {
6293
+ webContents: webContents4,
6294
+ ready: false,
6295
+ generation: 0,
6296
+ loaded: /* @__PURE__ */ new Set(),
6297
+ loading: /* @__PURE__ */ new Map(),
6298
+ onLoaded: () => {
6299
+ }
6300
+ };
6301
+ target.onLoaded = () => {
6302
+ if (active !== target || webContents4.isDestroyed()) return;
6303
+ target.ready = true;
6304
+ target.generation += 1;
6305
+ target.loaded.clear();
6306
+ target.loading.clear();
6307
+ void installAll(target);
6308
+ };
6309
+ active = target;
6310
+ webContents4.on("did-finish-load", target.onLoaded);
6311
+ }
6312
+ function register(registration) {
6313
+ assertRegistration(registration);
6314
+ const stableRegistration = { ...registration };
6315
+ const entry = {
6316
+ registration: stableRegistration
6317
+ };
6318
+ entries.set(stableRegistration.id, entry);
6319
+ const target = active;
6320
+ if (target?.ready) {
6321
+ void install(entry, target).catch((error) => {
6322
+ console.warn(
6323
+ `[simulator-ui] failed to install "${stableRegistration.id}":`,
6324
+ error instanceof Error ? error.message : String(error)
6325
+ );
6326
+ });
6327
+ }
6328
+ let disposed = false;
6329
+ return {
6330
+ dispose() {
6331
+ if (disposed) return;
6332
+ disposed = true;
6333
+ if (entries.get(stableRegistration.id) !== entry) return;
6334
+ entries.delete(stableRegistration.id);
6335
+ uninstall(stableRegistration.id);
6336
+ },
6337
+ async invoke(method, params) {
6338
+ if (disposed || entries.get(stableRegistration.id) !== entry) {
6339
+ throw new Error(`Simulator UI extension "${stableRegistration.id}" is disposed`);
6340
+ }
6341
+ const current = active;
6342
+ if (!current || current.webContents.isDestroyed() || !current.ready) {
6343
+ throw new Error("Dimina simulator UI is not ready");
6344
+ }
6345
+ await install(entry, current);
6346
+ if (active !== current || current.webContents.isDestroyed()) {
6347
+ throw new Error("Dimina simulator UI was replaced during invocation");
6348
+ }
6349
+ return await current.webContents.executeJavaScript(
6350
+ activeExpression("invoke", [stableRegistration.id, method, params ?? null]),
6351
+ true
6352
+ );
6353
+ }
6354
+ };
6355
+ }
6356
+ function clear() {
6357
+ for (const id of entries.keys()) uninstall(id);
6358
+ entries.clear();
6359
+ detach();
6360
+ }
6361
+ return { register, attach, detach, clear };
6362
+ }
6363
+
6195
6364
  // src/main/services/projects/templates.ts
6196
6365
  function resolveTemplates(builtin, injected, mode) {
6197
6366
  let kept;
@@ -6253,6 +6422,8 @@ function createWorkbenchContext(opts) {
6253
6422
  ctx.registry.add(() => ctx.cdpSessionBroker.dispose());
6254
6423
  ctx.trustedWindowSenderIds = /* @__PURE__ */ new Map();
6255
6424
  ctx.simulatorApis = createSimulatorApiRegistry();
6425
+ ctx.simulatorUiExtensions = createSimulatorUiExtensionRegistry();
6426
+ ctx.registry.add(() => ctx.simulatorUiExtensions.clear());
6256
6427
  ctx.windows = createWindowService(opts.mainWindow);
6257
6428
  ctx.views = createViewManager(ctx);
6258
6429
  ctx.registry.add(() => ctx.views.disposeAll());
@@ -6919,7 +7090,7 @@ import { DisposableRegistry as DisposableRegistry5 } from "@dimina-kit/electron-
6919
7090
  // src/main/ipc/bridge-router.ts
6920
7091
  import { app as app12, ipcMain as ipcMain3, protocol as protocol2, session as electronSession, webContents as webContents3 } from "electron";
6921
7092
  import path17 from "node:path";
6922
- import { pathToFileURL as pathToFileURL3 } from "node:url";
7093
+ import { pathToFileURL as pathToFileURL4 } from "node:url";
6923
7094
 
6924
7095
  // src/shared/request-core.ts
6925
7096
  var DEFAULT_REQUEST_TIMEOUT_MS = 6e4;
@@ -7073,7 +7244,7 @@ function setCorsHeaders(res) {
7073
7244
  // src/main/windows/service-host-window/create.ts
7074
7245
  import { app as app10, BrowserWindow as BrowserWindow5 } from "electron";
7075
7246
  import path16 from "node:path";
7076
- import { pathToFileURL as pathToFileURL2 } from "node:url";
7247
+ import { pathToFileURL as pathToFileURL3 } from "node:url";
7077
7248
  var SERVICE_HOST_PARTITION = SHARED_MINIAPP_PARTITION;
7078
7249
  var serviceHostPreloadPath = path16.join(devtoolsPackageRoot, "dist/service-host/preload.cjs");
7079
7250
  var serviceHostHtmlPath = path16.join(devtoolsPackageRoot, "dist/service-host/service.html");
@@ -7096,7 +7267,7 @@ function constructServiceHostWindow(opts = {}) {
7096
7267
  });
7097
7268
  }
7098
7269
  function buildServiceHostSpawnUrl(opts) {
7099
- const url = new URL(pathToFileURL2(serviceHostHtmlPath).toString());
7270
+ const url = new URL(pathToFileURL3(serviceHostHtmlPath).toString());
7100
7271
  url.searchParams.set("bridgeId", opts.bridgeId);
7101
7272
  url.searchParams.set("appId", opts.appId);
7102
7273
  url.searchParams.set("pagePath", opts.pagePath);
@@ -8297,8 +8468,8 @@ function installBridgeRouter(ctx) {
8297
8468
  const onNativeHostQuery = (event) => {
8298
8469
  const reply = {
8299
8470
  enabled: true,
8300
- renderHostHtmlUrl: pathToFileURL3(path17.join(devtoolsPackageRoot, "dist/render-host/pageFrame.html")).toString(),
8301
- renderPreloadUrl: pathToFileURL3(path17.join(devtoolsPackageRoot, "dist/render-host/preload.cjs")).toString(),
8471
+ renderHostHtmlUrl: pathToFileURL4(path17.join(devtoolsPackageRoot, "dist/render-host/pageFrame.html")).toString(),
8472
+ renderPreloadUrl: pathToFileURL4(path17.join(devtoolsPackageRoot, "dist/render-host/preload.cjs")).toString(),
8302
8473
  device: currentDevice ?? void 0
8303
8474
  };
8304
8475
  event.returnValue = reply;
@@ -13168,6 +13339,14 @@ async function createDevtoolsRuntime(config = {}, onInstanceCreated) {
13168
13339
  // teardown, so a single dispose leaves no dead entry behind.
13169
13340
  registerTrustedWindow: (win) => context.registry.add(registerTrustedWindow(context, win)),
13170
13341
  registerSimulatorApi: (name, handler) => context.registry.add(toDisposable8(context.simulatorApis.register(name, handler))),
13342
+ registerSimulatorUiExtension: (registration) => {
13343
+ const extension = context.simulatorUiExtensions.register(registration);
13344
+ const owned = context.registry.add(extension);
13345
+ return {
13346
+ dispose: () => owned.dispose(),
13347
+ invoke: (method, params) => extension.invoke(method, params)
13348
+ };
13349
+ },
13171
13350
  dispose: () => disposeContext(context)
13172
13351
  };
13173
13352
  onInstanceCreated?.(instance);
@@ -0,0 +1,13 @@
1
+ import type { WebContents } from 'electron';
2
+ import type { SimulatorUiExtensionHandle, SimulatorUiExtensionRegistration } from '../../../shared/simulator-ui.js';
3
+ export interface SimulatorUiExtensionRegistry {
4
+ register(registration: SimulatorUiExtensionRegistration): SimulatorUiExtensionHandle;
5
+ attach(webContents: WebContents): void;
6
+ detach(webContents?: WebContents): void;
7
+ clear(): void;
8
+ }
9
+ export interface SimulatorUiExtensionRegistryOptions {
10
+ loadSource?: (scriptPath: string) => Promise<string>;
11
+ }
12
+ export declare function createSimulatorUiExtensionRegistry(options?: SimulatorUiExtensionRegistryOptions): SimulatorUiExtensionRegistry;
13
+ //# sourceMappingURL=ui-extensions.d.ts.map
@@ -0,0 +1,166 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { isAbsolute } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ const EXTENSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
5
+ export function createSimulatorUiExtensionRegistry(options = {}) {
6
+ const loadSource = options.loadSource ?? (scriptPath => readFile(scriptPath, 'utf8'));
7
+ const entries = new Map();
8
+ let active = null;
9
+ function assertRegistration(registration) {
10
+ if (!EXTENSION_ID_RE.test(registration.id)) {
11
+ throw new Error(`Invalid simulator UI extension id: "${registration.id}"`);
12
+ }
13
+ if (!isAbsolute(registration.rendererScriptPath)) {
14
+ throw new Error('Simulator UI extension rendererScriptPath must be absolute');
15
+ }
16
+ if (entries.has(registration.id)) {
17
+ throw new Error(`Simulator UI extension "${registration.id}" is already registered`);
18
+ }
19
+ }
20
+ function activeExpression(method, args) {
21
+ return `globalThis.__diminaSimulatorUi.${method}(${args.map(arg => JSON.stringify(arg)).join(',')})`;
22
+ }
23
+ async function install(entry, target) {
24
+ const { id, rendererScriptPath } = entry.registration;
25
+ if (target.loaded.has(id))
26
+ return;
27
+ const pending = target.loading.get(id);
28
+ if (pending)
29
+ return await pending;
30
+ const generation = target.generation;
31
+ const task = (async () => {
32
+ // Read once per simulator document generation so downstream watch builds
33
+ // become visible after a hard reload without restarting the workbench.
34
+ const source = await loadSource(rendererScriptPath);
35
+ if (entries.get(id) !== entry
36
+ ||
37
+ active !== target
38
+ || target.webContents.isDestroyed()
39
+ || !target.ready
40
+ || target.generation !== generation) {
41
+ throw new Error('Simulator UI extension target is no longer available');
42
+ }
43
+ const sourceUrl = pathToFileURL(rendererScriptPath).href;
44
+ const wrapped = `(()=>{\n${source}\n;return undefined\n})()\n//# sourceURL=${sourceUrl}`;
45
+ await target.webContents.executeJavaScript(wrapped, true);
46
+ if (entries.get(id) !== entry
47
+ ||
48
+ active !== target
49
+ || target.webContents.isDestroyed()
50
+ || !target.ready
51
+ || target.generation !== generation) {
52
+ throw new Error('Simulator UI extension target was destroyed during installation');
53
+ }
54
+ const registered = await target.webContents.executeJavaScript(`Boolean(globalThis.__diminaSimulatorUi?.has(${JSON.stringify(id)}))`, true);
55
+ if (!registered) {
56
+ throw new Error(`Simulator UI extension "${id}" did not register its renderer implementation`);
57
+ }
58
+ target.loaded.add(id);
59
+ })().finally(() => {
60
+ if (target.loading.get(id) === task)
61
+ target.loading.delete(id);
62
+ });
63
+ target.loading.set(id, task);
64
+ return await task;
65
+ }
66
+ async function installAll(target) {
67
+ for (const entry of entries.values()) {
68
+ try {
69
+ await install(entry, target);
70
+ }
71
+ catch (error) {
72
+ console.warn(`[simulator-ui] failed to install "${entry.registration.id}":`, error instanceof Error ? error.message : String(error));
73
+ }
74
+ }
75
+ }
76
+ function uninstall(id, target = active) {
77
+ if (!target)
78
+ return;
79
+ target.loaded.delete(id);
80
+ target.loading.delete(id);
81
+ if (!target.ready || target.webContents.isDestroyed())
82
+ return;
83
+ void target.webContents.executeJavaScript(`globalThis.__diminaSimulatorUi?.unregister(${JSON.stringify(id)})`, true).catch(() => { });
84
+ }
85
+ function detach(webContents) {
86
+ const target = active;
87
+ if (!target || (webContents && target.webContents !== webContents))
88
+ return;
89
+ active = null;
90
+ if (!target.webContents.isDestroyed()) {
91
+ target.webContents.removeListener('did-finish-load', target.onLoaded);
92
+ }
93
+ target.loaded.clear();
94
+ target.loading.clear();
95
+ }
96
+ function attach(webContents) {
97
+ detach();
98
+ const target = {
99
+ webContents,
100
+ ready: false,
101
+ generation: 0,
102
+ loaded: new Set(),
103
+ loading: new Map(),
104
+ onLoaded: () => { },
105
+ };
106
+ target.onLoaded = () => {
107
+ if (active !== target || webContents.isDestroyed())
108
+ return;
109
+ target.ready = true;
110
+ target.generation += 1;
111
+ target.loaded.clear();
112
+ target.loading.clear();
113
+ void installAll(target);
114
+ };
115
+ active = target;
116
+ webContents.on('did-finish-load', target.onLoaded);
117
+ }
118
+ function register(registration) {
119
+ assertRegistration(registration);
120
+ const stableRegistration = { ...registration };
121
+ const entry = {
122
+ registration: stableRegistration,
123
+ };
124
+ entries.set(stableRegistration.id, entry);
125
+ const target = active;
126
+ if (target?.ready) {
127
+ void install(entry, target).catch((error) => {
128
+ console.warn(`[simulator-ui] failed to install "${stableRegistration.id}":`, error instanceof Error ? error.message : String(error));
129
+ });
130
+ }
131
+ let disposed = false;
132
+ return {
133
+ dispose() {
134
+ if (disposed)
135
+ return;
136
+ disposed = true;
137
+ if (entries.get(stableRegistration.id) !== entry)
138
+ return;
139
+ entries.delete(stableRegistration.id);
140
+ uninstall(stableRegistration.id);
141
+ },
142
+ async invoke(method, params) {
143
+ if (disposed || entries.get(stableRegistration.id) !== entry) {
144
+ throw new Error(`Simulator UI extension "${stableRegistration.id}" is disposed`);
145
+ }
146
+ const current = active;
147
+ if (!current || current.webContents.isDestroyed() || !current.ready) {
148
+ throw new Error('Dimina simulator UI is not ready');
149
+ }
150
+ await install(entry, current);
151
+ if (active !== current || current.webContents.isDestroyed()) {
152
+ throw new Error('Dimina simulator UI was replaced during invocation');
153
+ }
154
+ return await current.webContents.executeJavaScript(activeExpression('invoke', [stableRegistration.id, method, params ?? null]), true);
155
+ },
156
+ };
157
+ }
158
+ function clear() {
159
+ for (const id of entries.keys())
160
+ uninstall(id);
161
+ entries.clear();
162
+ detach();
163
+ }
164
+ return { register, attach, detach, clear };
165
+ }
166
+ //# sourceMappingURL=ui-extensions.js.map
@@ -70,6 +70,7 @@ export function createNativeSimulatorView(ctx, reconciler, deps) {
70
70
  function tearDownNativeSimulatorView(label) {
71
71
  if (!nativeSimulatorView)
72
72
  return;
73
+ ctx.simulatorUiExtensions?.detach(nativeSimulatorView.webContents);
73
74
  if (nativeSimulatorViewAdded && !ctx.windows.mainWindow.isDestroyed()) {
74
75
  try {
75
76
  ctx.windows.mainWindow.contentView.removeChildView(nativeSimulatorView);
@@ -219,6 +220,8 @@ export function createNativeSimulatorView(ctx, reconciler, deps) {
219
220
  // and the renderer placeholder behind it are all the same color.
220
221
  view.setBackgroundColor(simDeskBg());
221
222
  const simWc = view.webContents;
223
+ // Keep downstream UI bundles bound to this WCV until shared teardown.
224
+ ctx.simulatorUiExtensions?.attach(simWc);
222
225
  // Keep the WCV surface in sync with the active color scheme. The
223
226
  // process-wide installThemeBackgroundSync() re-syncs BrowserWindows on a
224
227
  // theme switch, but this top-level WebContentsView is not a window, so its
@@ -64,6 +64,11 @@ export interface ViewManagerContext {
64
64
  * Optional so partial test contexts compile.
65
65
  */
66
66
  simulatorApis?: WorkbenchContext['simulatorApis'];
67
+ /**
68
+ * Host-registered UI extensions that follow the native simulator WCV.
69
+ * Optional only for focused view-manager test doubles.
70
+ */
71
+ simulatorUiExtensions?: WorkbenchContext['simulatorUiExtensions'];
67
72
  }
68
73
  /**
69
74
  * Unified lifecycle manager for Electron WebContentsView overlays.
@@ -18,6 +18,7 @@ import { type ViewManager } from './views/view-manager.js';
18
18
  import { type WindowService } from './window-service.js';
19
19
  import { type WorkspaceService } from './workspace/workspace-service.js';
20
20
  import { type SimulatorApiRegistry } from './simulator/custom-apis.js';
21
+ import { type SimulatorUiExtensionRegistry } from './simulator/ui-extensions.js';
21
22
  import { sanitizeTemplates } from './projects/templates.js';
22
23
  import type { ProjectsProvider, ProjectTemplate } from './projects/types.js';
23
24
  import type { CustomCreateProjectDialogResult } from '../../shared/types.js';
@@ -123,6 +124,11 @@ export interface WorkbenchContext {
123
124
  * One registry per context — no process-global crosstalk.
124
125
  */
125
126
  simulatorApis: SimulatorApiRegistry;
127
+ /**
128
+ * Per-context downstream simulator UI extensions. It owns renderer bundle
129
+ * installation and follows the active native simulator WebContentsView.
130
+ */
131
+ simulatorUiExtensions: SimulatorUiExtensionRegistry;
126
132
  /** Aggregates dispose handlers for every IPC handler, listener, watcher, and CDP session registered by the workbench. */
127
133
  registry: DisposableRegistry;
128
134
  /**
@@ -12,6 +12,7 @@ import { openSettingsWindow } from '../windows/settings-window/index.js';
12
12
  import { createWorkspaceService, } from './workspace/workspace-service.js';
13
13
  import { createLocalProjectsProvider } from './projects/local-provider.js';
14
14
  import { createSimulatorApiRegistry, } from './simulator/custom-apis.js';
15
+ import { createSimulatorUiExtensionRegistry, } from './simulator/ui-extensions.js';
15
16
  import { resolveTemplates, sanitizeTemplates } from './projects/templates.js';
16
17
  import { BUILTIN_TEMPLATES } from './projects/builtin-templates.js';
17
18
  export function createWorkbenchContext(opts) {
@@ -34,6 +35,8 @@ export function createWorkbenchContext(opts) {
34
35
  ctx.registry.add(() => ctx.cdpSessionBroker.dispose());
35
36
  ctx.trustedWindowSenderIds = new Map();
36
37
  ctx.simulatorApis = createSimulatorApiRegistry();
38
+ ctx.simulatorUiExtensions = createSimulatorUiExtensionRegistry();
39
+ ctx.registry.add(() => ctx.simulatorUiExtensions.clear());
37
40
  ctx.windows = createWindowService(opts.mainWindow);
38
41
  ctx.views = createViewManager(ctx);
39
42
  // Full view teardown belongs to the CONTEXT's life, not a project's: