@dimina-kit/devtools 0.4.0-dev.20260625063832 → 0.4.0-dev.20260629114208

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 (64) hide show
  1. package/dist/main/app/app.js +2 -0
  2. package/dist/main/index.bundle.js +230 -144
  3. package/dist/main/ipc/bridge-router.d.ts +21 -0
  4. package/dist/main/ipc/bridge-router.js +63 -3
  5. package/dist/main/services/automation/index.js +0 -1
  6. package/dist/main/services/elements-forward/index.js +81 -72
  7. package/dist/main/services/mcp/target-manager.d.ts +3 -3
  8. package/dist/main/services/views/miniapp-partition.d.ts +18 -8
  9. package/dist/main/services/views/miniapp-partition.js +34 -13
  10. package/dist/main/services/views/view-manager.js +59 -36
  11. package/dist/main/services/workbench-coi-server.d.ts +7 -0
  12. package/dist/main/services/workbench-coi-server.js +6 -0
  13. package/dist/main/services/workbench-context.d.ts +4 -2
  14. package/dist/main/services/workbench-context.js +1 -0
  15. package/dist/main/services/workspace/op-lock.d.ts +26 -0
  16. package/dist/main/services/workspace/op-lock.js +23 -0
  17. package/dist/main/services/workspace/workspace-service.d.ts +7 -0
  18. package/dist/main/services/workspace/workspace-service.js +197 -103
  19. package/dist/main/windows/service-host-window/create.d.ts +5 -1
  20. package/dist/main/windows/service-host-window/create.js +6 -3
  21. package/dist/native-host/render/render.js +485 -449
  22. package/dist/native-host/service/service.js +1 -1
  23. package/dist/renderer/assets/index-BMwmmaSp.js +46 -0
  24. package/dist/renderer/entries/main/index.html +1 -1
  25. package/dist/shared/types.d.ts +15 -0
  26. package/dist/vscode-workbench/assets/__vite-browser-external-B0DTNerG.js +1 -0
  27. package/dist/vscode-workbench/assets/__vite-browser-external-BuLjVMfN.js +1 -0
  28. package/dist/vscode-workbench/assets/__vite-browser-external-thkx7I19.js +1 -0
  29. package/dist/vscode-workbench/assets/{dist-SFZ2HNEz.js → dist-CS7SQP3K.js} +4 -4
  30. package/dist/vscode-workbench/assets/dist-VSgdjUlW.js +8 -0
  31. package/dist/vscode-workbench/assets/editor.worker-DmMg51ee.js +41 -0
  32. package/dist/vscode-workbench/assets/extensionHost.worker-BMpl1cpB.js +207 -0
  33. package/dist/vscode-workbench/assets/{iconv-lite-umd-izd5l6fa.js → iconv-lite-umd-D3q-1-o6.js} +1 -1
  34. package/dist/vscode-workbench/assets/{iconv-lite-umd-COfjYshr.js → iconv-lite-umd-Jn9RTrsv.js} +2 -2
  35. package/dist/vscode-workbench/assets/index-zjigpZ25.js +2842 -0
  36. package/dist/vscode-workbench/assets/{jschardet-Bf-VMK4F.js → jschardet-CjCVc2Gw.js} +1 -1
  37. package/dist/vscode-workbench/assets/{jschardet-Dh5TyKsQ.js → jschardet-Cu4ufeHq.js} +1 -1
  38. package/dist/vscode-workbench/assets/{layout.contribution.darwin-cz2ttHTW.js → layout.contribution.darwin-CoH1BZtz.js} +1 -1
  39. package/dist/vscode-workbench/assets/{layout.contribution.linux-fEtdzbcY.js → layout.contribution.linux-Di2FF-CG.js} +1 -1
  40. package/dist/vscode-workbench/assets/{layout.contribution.win-BFyOKI8c.js → layout.contribution.win-moiNStil.js} +1 -1
  41. package/dist/vscode-workbench/assets/{main-Bh8TI5LP.js → main-DzHL45xo.js} +4 -4
  42. package/dist/vscode-workbench/assets/{main2-Bds9Fhfn.js → main2-CWVd-Hyp.js} +1 -1
  43. package/dist/vscode-workbench/assets/{worker-D9ti6A0r.js → worker-CRTb-Arc.js} +7 -7
  44. package/dist/vscode-workbench/assets/{worker-DQtXDXIZ.js → worker-CVB8WPuM.js} +2 -2
  45. package/dist/vscode-workbench/index.html +1 -4
  46. package/package.json +7 -37
  47. package/dist/renderer/assets/index-6XUSo6Su.js +0 -46
  48. package/dist/vscode-workbench/assets/__vite-browser-external-BUwiolNy.js +0 -1
  49. package/dist/vscode-workbench/assets/__vite-browser-external-CmG9GjjO.js +0 -1
  50. package/dist/vscode-workbench/assets/__vite-browser-external-DGJPTKLg.js +0 -1
  51. package/dist/vscode-workbench/assets/_commonjsHelpers-qcUHfnrg.js +0 -1
  52. package/dist/vscode-workbench/assets/chunk-B3K2TuZy.js +0 -1
  53. package/dist/vscode-workbench/assets/chunk-DECur_0Z.js +0 -1
  54. package/dist/vscode-workbench/assets/dist-tesJ1Kd2.js +0 -8
  55. package/dist/vscode-workbench/assets/editor.worker-BC1qOyXG.js +0 -41
  56. package/dist/vscode-workbench/assets/extension.api-BWA2aPAU.js +0 -1
  57. package/dist/vscode-workbench/assets/extensionHost.worker-Dvu35anN.js +0 -207
  58. package/dist/vscode-workbench/assets/index-CQ5xP0pp.js +0 -1656
  59. package/dist/vscode-workbench/assets/localExtensionHost-Ccz7QUdA.js +0 -1188
  60. package/dist/vscode-workbench/assets/main-BDgdQmvs.js +0 -4
  61. package/dist/vscode-workbench/assets/main2-DwfDVleQ.js +0 -1
  62. package/dist/vscode-workbench/assets/preload-helper-xBbMyY7u.js +0 -1
  63. /package/dist/vscode-workbench/assets/{_.contribution-C9aS_G-o.js → _.contribution-B46wcGKQ.js} +0 -0
  64. /package/dist/vscode-workbench/assets/{_commonjsHelpers-BPKu-ps5.js → _commonjsHelpers-DMmVv2OA.js} +0 -0
@@ -317,6 +317,12 @@ export async function startWorkbenchCoiServer(options) {
317
317
  });
318
318
  return;
319
319
  }
320
+ // Host custom file types for the editor's `files.associations`. Read-only;
321
+ // empty object when the host configured none.
322
+ if (url.pathname === '/__filetypes') {
323
+ jsonRes(res, 200, options.getFileTypes?.() ?? {});
324
+ return;
325
+ }
320
326
  // Host-contributed web extensions: manifest + static files (same-origin so
321
327
  // they satisfy the workbench's COEP require-corp).
322
328
  if (url.pathname.startsWith('/__contrib/')) {
@@ -1,5 +1,5 @@
1
1
  import type { BrowserWindow } from 'electron';
2
- import type { CompilationAdapter, WorkbenchConfig } from '../../shared/types.js';
2
+ import type { CompilationAdapter, CustomFileTypes, WorkbenchConfig } from '../../shared/types.js';
3
3
  import type { BridgeRouterHandle } from '../ipc/bridge-router.js';
4
4
  import type { ConsoleForwarder } from './console-forward/index.js';
5
5
  import type { NetworkForwarder } from './network-forward/index.js';
@@ -27,6 +27,8 @@ export interface WorkbenchContext {
27
27
  rendererDir: string;
28
28
  /** Custom API namespace names (e.g. ['qd']) passed to the simulator */
29
29
  apiNamespaces: string[];
30
+ /** Custom file types (e.g. .qdml/.qdss/.qds) forwarded to compilation and served to the editor via the COI `/__filetypes` endpoint. */
31
+ fileTypes: CustomFileTypes;
30
32
  /** Branding name shown in title bar and getBranding IPC */
31
33
  appName: string;
32
34
  /** Host-injected provider for branding info (overrides default appName) */
@@ -178,7 +180,7 @@ export interface WorkbenchContext {
178
180
  * - `projectsProvider` / `projectTemplates` / `customCreateProjectDialog` use
179
181
  * the main-process types, not the structural mirrors in `shared/types`.
180
182
  */
181
- export interface CreateContextOptions extends Pick<WorkbenchConfig, 'adapter' | 'apiNamespaces' | 'appName'> {
183
+ export interface CreateContextOptions extends Pick<WorkbenchConfig, 'adapter' | 'apiNamespaces' | 'appName' | 'fileTypes'> {
182
184
  mainWindow: BrowserWindow;
183
185
  preloadPath: string;
184
186
  rendererDir: string;
@@ -16,6 +16,7 @@ export function createWorkbenchContext(opts) {
16
16
  preloadPath: opts.preloadPath,
17
17
  rendererDir: opts.rendererDir,
18
18
  apiNamespaces: opts.apiNamespaces ?? [],
19
+ fileTypes: opts.fileTypes ?? {},
19
20
  appName: opts.appName ?? 'Dimina DevTools',
20
21
  brandingProvider: opts.brandingProvider,
21
22
  };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Lifecycle serialization primitive for the workspace's openProject/closeProject.
3
+ *
4
+ * Two parts working together (see workspace-service for the full rationale):
5
+ * - A FIFO async lock so the teardown/commit critical sections of open and
6
+ * close never interleave. The unbounded compile runs between an open's two
7
+ * sections WITHOUT holding the lock, so a queued close is never blocked by a
8
+ * slow/hung compile (close is bounded by disposeSession's own timeout).
9
+ * - A latest-wins request token. `nextSeq()` is claimed at the START of every
10
+ * op (before any await) so it encodes REQUEST order, not hook/compile
11
+ * completion order. `takeOwnership` promotes a seq (highest wins); a section
12
+ * that finds `!isOwner(mySeq)` was superseded while it released the lock and
13
+ * must abort instead of clobbering the newer request's runtime.
14
+ */
15
+ export interface OpLock {
16
+ /** Claim a monotonic request seq. Call once at op entry, before any await. */
17
+ nextSeq(): number;
18
+ /** Promote a seq to the runtime owner. Only the highest seq ever wins. */
19
+ takeOwnership(seq: number): void;
20
+ /** Whether `seq` is still the current owner (false ⇒ superseded). */
21
+ isOwner(seq: number): boolean;
22
+ /** Acquire the FIFO lock; await the result, then call it to release. */
23
+ acquire(): Promise<() => void>;
24
+ }
25
+ export declare function createOpLock(): OpLock;
26
+ //# sourceMappingURL=op-lock.d.ts.map
@@ -0,0 +1,23 @@
1
+ export function createOpLock() {
2
+ let chain = Promise.resolve();
3
+ let requestSeq = 0;
4
+ let ownerSeq = 0;
5
+ return {
6
+ nextSeq: () => ++requestSeq,
7
+ takeOwnership: (seq) => {
8
+ if (seq > ownerSeq)
9
+ ownerSeq = seq;
10
+ },
11
+ isOwner: (seq) => seq === ownerSeq,
12
+ acquire: () => {
13
+ let release;
14
+ const next = new Promise((resolve) => {
15
+ release = resolve;
16
+ });
17
+ const prior = chain;
18
+ chain = prior.then(() => next);
19
+ return prior.then(() => release);
20
+ },
21
+ };
22
+ }
23
+ //# sourceMappingURL=op-lock.js.map
@@ -43,6 +43,13 @@ export interface WorkspaceService {
43
43
  */
44
44
  getLastClosedProjectPath(): string;
45
45
  hasActiveSession(): boolean;
46
+ /**
47
+ * True only while `closeProject` is tearing down. Bridge active-content
48
+ * resolution consults this to avoid resolving the dying session's guest during
49
+ * the close's session.close() await (when currentSession is already null but
50
+ * the bridge app session still exists).
51
+ */
52
+ isClosing(): boolean;
46
53
  captureThumbnail(projectPath: string): Promise<string | null>;
47
54
  getThumbnail(projectPath: string): Promise<string | null>;
48
55
  getProjectPages(projectPath: string): ProjectPages;
@@ -3,17 +3,23 @@ import * as repo from '../projects/project-repository.js';
3
3
  import { DEFAULT_COMPILE_CONFIG } from '../projects/types.js';
4
4
  import { clearSimulatorServicewechatReferer, setSimulatorServicewechatReferer, } from '../simulator/referer.js';
5
5
  import { loadWorkbenchSettings } from '../settings/index.js';
6
+ import { createOpLock } from './op-lock.js';
6
7
  /**
7
- * Runtime guard for the adapter-return boundary: `session.appInfo` must be an
8
- * object carrying a string `appId` (the renderer derives its IPC scoping from
9
- * it; a session without one cannot be driven). Loose: extra fields pass
10
- * through untouched — only the contract-critical `appId` is enforced.
8
+ * Runtime guard for the adapter-return boundary: `session.appInfo` must carry a
9
+ * NON-EMPTY string `appId` (the renderer scopes IPC by it; the bridge's
10
+ * handleSpawn also rejects empty appIds, so accepting `''` would desync layers).
11
+ * Loose: extra fields pass through — only the contract-critical `appId` is enforced.
11
12
  */
12
- const SessionAppInfoSchema = z.looseObject({ appId: z.string() });
13
+ const SessionAppInfoSchema = z.looseObject({ appId: z.string().min(1) });
13
14
  /** Build a workspace service bound to the given workbench context. */
14
15
  export function createWorkspaceService(ctx) {
15
16
  let currentSession = null;
16
17
  let currentProjectPath = '';
18
+ // True only while closeProject tears down. disposeSession nulls currentSession
19
+ // before awaiting session.close(), but the bridge app session lives until the
20
+ // later disposeAll — bridge getters consult this to refuse resolving the dying
21
+ // project's guest during that window.
22
+ let closing = false;
17
23
  // The last project path cleared by `closeProject`; consumed by project-fs to
18
24
  // accept an in-flight write that targets the just-closed project. Reset at
19
25
  // the start of every `openProject` so it never accumulates a stale root.
@@ -28,6 +34,10 @@ export function createWorkspaceService(ctx) {
28
34
  // first compile's log lines arrive while the adapter promise is still
29
35
  // pending, before currentSession is assigned.)
30
36
  let logGeneration = 0;
37
+ // Serializes openProject/closeProject teardown/commit sections (FIFO) + latest-
38
+ // wins request tokens, so a close mid-open can't disposeAll() the views the open
39
+ // is building or desync currentSession vs the bridge appSessions. See op-lock.ts.
40
+ const opLock = createOpLock();
31
41
  function sendStatus(status, message, hotReload) {
32
42
  ctx.notify.projectStatus(hotReload ? { status, message, hotReload: true } : { status, message });
33
43
  }
@@ -54,6 +64,102 @@ export function createWorkspaceService(ctx) {
54
64
  console.error('[workspace] session close failed:', err);
55
65
  }
56
66
  }
67
+ /**
68
+ * openProject critical section 1 — tear down the outgoing runtime under the op
69
+ * lock (the unbounded compile runs after this releases, so a queued close isn't
70
+ * blocked). Returns true when superseded (caller aborts without touching the
71
+ * newer request's runtime).
72
+ */
73
+ async function runOpenTeardown(mySeq) {
74
+ const release = await opLock.acquire();
75
+ try {
76
+ if (!opLock.isOwner(mySeq))
77
+ return true;
78
+ clearSimulatorServicewechatReferer();
79
+ // Switching projects tears down the embedded workbench editor (mirrors one
80
+ // project's disk; saves would else hit the wrong project) AND the native
81
+ // simulator (its 'destroyed' hook owns the bridge session). Back-to-list
82
+ // never calls closeProject, so this is the only deterministic teardown.
83
+ if (currentSession !== null) {
84
+ ctx.views.detachWorkbench();
85
+ ctx.views.detachSimulator();
86
+ }
87
+ // Invalidate the outgoing onLog generation before teardown so the dying
88
+ // worker's buffered lines can't pollute the incoming compile timeline.
89
+ logGeneration++;
90
+ await disposeSession();
91
+ currentProjectPath = '';
92
+ lastClosedProjectPath = '';
93
+ return false;
94
+ }
95
+ finally {
96
+ release();
97
+ }
98
+ }
99
+ /**
100
+ * openProject critical section 2 — commit the new session under the lock, only
101
+ * if still the latest request. Returns false when superseded (caller discards
102
+ * the freshly-opened session instead of clobbering the newer one).
103
+ */
104
+ async function runOpenCommit(mySeq, session, projectPath) {
105
+ const release = await opLock.acquire();
106
+ try {
107
+ if (!opLock.isOwner(mySeq))
108
+ return false;
109
+ currentSession = session;
110
+ currentProjectPath = projectPath;
111
+ return true;
112
+ }
113
+ finally {
114
+ release();
115
+ }
116
+ }
117
+ /**
118
+ * Drive the compile adapter for one open (OUTSIDE the op lock): returns the
119
+ * session or an error message, never throws. The onLog generation guard drops
120
+ * a superseded session's buffered lines.
121
+ */
122
+ async function runCompile(projectPath, sessionGeneration) {
123
+ const { compile } = loadWorkbenchSettings();
124
+ try {
125
+ const session = await ctx.adapter.openProject({
126
+ projectPath,
127
+ sourcemap: true,
128
+ fileTypes: ctx.fileTypes,
129
+ watch: compile.watch,
130
+ onRebuild: () => sendStatus('ready', '编译完成,已热更新', true),
131
+ onBuildError: (err) => sendStatus('error', String(err)),
132
+ onLog: (entry) => {
133
+ if (sessionGeneration !== logGeneration)
134
+ return;
135
+ ctx.notify.compileLog({ stream: entry.stream, text: entry.text, at: Date.now() });
136
+ },
137
+ });
138
+ return { session };
139
+ }
140
+ catch (err) {
141
+ clearSimulatorServicewechatReferer();
142
+ return { error: String(err) };
143
+ }
144
+ }
145
+ /**
146
+ * Adapter-return boundary: a session without a string appId can't be driven by
147
+ * the renderer, so it must never become active. Close the live resources the
148
+ * adapter already spun up (best-effort) and return the error message; null when
149
+ * valid.
150
+ */
151
+ async function rejectInvalidAppId(session) {
152
+ if (SessionAppInfoSchema.safeParse(session.appInfo).success)
153
+ return null;
154
+ try {
155
+ await session.close();
156
+ }
157
+ catch (closeErr) {
158
+ console.warn('[workspace] closing appId-less adapter session failed (non-fatal):', closeErr);
159
+ }
160
+ return 'adapter returned session.appInfo without a string appId — '
161
+ + 'the CompilationAdapter must supply appInfo.appId';
162
+ }
57
163
  function applyRefererFromSession(session) {
58
164
  const appInfo = session.appInfo;
59
165
  if (appInfo && typeof appInfo.appId === 'string' && appInfo.appId.length > 0) {
@@ -80,54 +186,29 @@ export function createWorkspaceService(ctx) {
80
186
  ? provider.validateProjectDir(dirPath)
81
187
  : null,
82
188
  async openProject(projectPath) {
83
- // Host permission gate runs BEFORE any side effect (referer reset,
84
- // session teardown, compile/dev-server spin-up). A throwing hook vetoes
85
- // the open: return early with the error and leave the currently-active
86
- // session fully intact. The declarative replacement for monkey-patching
87
- // this method. See WorkbenchAppConfig.onBeforeOpenProject.
189
+ // Claim a request seq before the veto hook's await so latest-wins is by
190
+ // REQUEST order, not by which hook resolves first.
191
+ const mySeq = opLock.nextSeq();
192
+ // Host permission gate (WorkbenchAppConfig.onBeforeOpenProject): a throwing
193
+ // hook vetoes the open and leaves the active session intact. Runs before
194
+ // any side effect and outside the op lock/token.
88
195
  if (ctx.onBeforeOpenProject) {
89
196
  try {
90
197
  await ctx.onBeforeOpenProject(projectPath);
91
198
  }
92
199
  catch (err) {
93
- // Surface the veto to the status bar — symmetric with the
94
- // validateProjectDir rejection below (which already emits an error
95
- // status). The framework caught the error, so it owns the minimal
96
- // user-visible feedback; a host gate must not have to thread
97
- // `notify` through a singleton just to report why the open was
98
- // denied. The host can still layer richer UX (e.g. a dialog).
99
200
  const error = err instanceof Error ? err.message : String(err);
100
201
  sendStatus('error', error);
101
202
  return { success: false, error };
102
203
  }
103
204
  }
104
- clearSimulatorServicewechatReferer();
105
- // Switching from one project to another must tear down the embedded
106
- // workbench editor view. The workbench mirrors a single project's disk
107
- // to `file:///workspace` ONCE at boot and routes `/__fs` writes at the
108
- // live active project root; if the WCV survives a switch, the editor
109
- // keeps showing project A's mirrored tree while saves land in project B
110
- // (wrong project). Detaching destroys the WCV so the next time the
111
- // 'editor' slot becomes visible it lazily re-attaches and re-mirrors the
112
- // new project (setWorkbenchBounds re-creates from the stored source on
113
- // the first non-zero rect). Guarded on an active session so the first
114
- // open (no predecessor) is a safe no-op.
115
- if (currentSession !== null) {
116
- ctx.views.detachWorkbench();
205
+ // Passed the veto — take ownership (a vetoed open never supersedes an
206
+ // in-flight one; ownerSeq only grows, so a late-resolving earlier request
207
+ // can't clobber a later one that already took over).
208
+ opLock.takeOwnership(mySeq);
209
+ if (await runOpenTeardown(mySeq)) {
210
+ return { success: false, error: 'superseded by a newer project open/close' };
117
211
  }
118
- // Invalidate the outgoing session's onLog BEFORE teardown starts (same
119
- // order as closeProject below): the dying compile worker flushes
120
- // buffered lines DURING disposeSession(), and with the old generation
121
- // still current they would pass the staleness guard and pollute the
122
- // incoming project's compile-log timeline. The new session claims its
123
- // own fresh generation further down (after dispose), so its onLog
124
- // forwards normally.
125
- logGeneration++;
126
- await disposeSession();
127
- currentProjectPath = '';
128
- // A fresh open starts a clean sandbox window — drop any previously
129
- // recorded last-closed root so it can't accept writes after this point.
130
- lastClosedProjectPath = '';
131
212
  // Reject obviously broken projects up front so we never spin up a
132
213
  // compile/server that the simulator will then fetch asset-by-asset.
133
214
  // Without this guard, deleted recent-list entries and typo'd CLI paths
@@ -143,61 +224,32 @@ export function createWorkspaceService(ctx) {
143
224
  return { success: false, error: dirError };
144
225
  }
145
226
  sendStatus('compiling', '正在编译...');
146
- const { compile } = loadWorkbenchSettings();
147
227
  // This open owns the log channel until the next open/close bumps the
148
- // generation — the onLog closure below checks it per line.
228
+ // generation — runCompile's onLog closure checks it per line.
149
229
  const sessionGeneration = ++logGeneration;
150
- let session;
151
- try {
152
- session = await ctx.adapter.openProject({
153
- projectPath,
154
- sourcemap: true,
155
- watch: compile.watch,
156
- onRebuild: () => sendStatus('ready', '编译完成,已热更新', true),
157
- onBuildError: (err) => sendStatus('error', String(err)),
158
- // Per-line dmcc log (already filtered in devkit). Stamp the
159
- // wall-clock capture time here and push verbatim on the dedicated
160
- // compile-log channel — never through projectStatus, whose
161
- // one-event-per-payload contract feeds compileEvents. Stale lines
162
- // (a closed/replaced session's worker flushing its buffers) are
163
- // dropped via the generation check.
164
- onLog: (entry) => {
165
- if (sessionGeneration !== logGeneration)
166
- return;
167
- ctx.notify.compileLog({
168
- stream: entry.stream,
169
- text: entry.text,
170
- at: Date.now(),
171
- });
172
- },
173
- });
230
+ const compiled = await runCompile(projectPath, sessionGeneration);
231
+ if ('error' in compiled) {
232
+ sendStatus('error', compiled.error);
233
+ return { success: false, error: compiled.error };
174
234
  }
175
- catch (err) {
176
- clearSimulatorServicewechatReferer();
177
- sendStatus('error', String(err));
178
- return { success: false, error: String(err) };
235
+ const session = compiled.session;
236
+ const appIdError = await rejectInvalidAppId(session);
237
+ if (appIdError) {
238
+ sendStatus('error', appIdError);
239
+ return { success: false, error: appIdError };
179
240
  }
180
- // Adapter-return boundary: enforce the AppInfo producer contract the
181
- // moment the adapter resolves, BEFORE the session is recorded. A
182
- // session without a string appId cannot be driven by the renderer, so
183
- // it must never become the active session. The adapter already spun up
184
- // live resources (compile watcher, dev-server port) — close them
185
- // best-effort before reporting; the validation report is the law and a
186
- // failing close() must not mask it (or escape as a throw).
187
- if (!SessionAppInfoSchema.safeParse(session.appInfo).success) {
241
+ if (!(await runOpenCommit(mySeq, session, projectPath))) {
242
+ // Superseded during the compile above discard the freshly-opened
243
+ // session (close its live compile/dev-server) rather than clobber the
244
+ // newer request.
188
245
  try {
189
246
  await session.close();
190
247
  }
191
248
  catch (closeErr) {
192
- console.warn('[workspace] closing appId-less adapter session failed (non-fatal):', closeErr);
249
+ console.warn('[workspace] closing superseded open session failed (non-fatal):', closeErr);
193
250
  }
194
- const error = 'adapter returned session.appInfo without a string appId ' +
195
- 'the CompilationAdapter must supply appInfo.appId';
196
- sendStatus('error', error);
197
- return { success: false, error };
251
+ return { success: false, error: 'superseded by a newer project open/close' };
198
252
  }
199
- currentSession = session;
200
- currentProjectPath = projectPath;
201
253
  bestEffort('updateLastOpened', () => {
202
254
  if (provider.updateLastOpened)
203
255
  provider.updateLastOpened(projectPath);
@@ -211,37 +263,79 @@ export function createWorkspaceService(ctx) {
211
263
  };
212
264
  },
213
265
  async closeProject() {
214
- // Invalidate the active session's onLog BEFORE teardown starts lines
215
- // flushed by the dying compile worker must not reach the panel.
216
- logGeneration++;
217
- clearSimulatorServicewechatReferer();
218
- await disposeSession();
219
- // Record the root being torn down BEFORE clearing it, so a teardown/
220
- // debounced write already in flight can still be accepted against it.
221
- if (currentProjectPath !== '')
222
- lastClosedProjectPath = currentProjectPath;
223
- currentProjectPath = '';
224
- ctx.views.disposeAll();
266
+ // Claim a request seq and take ownership immediately (close has no veto),
267
+ // then serialize against a concurrent openProject: disposeAll() must not
268
+ // run while an open is mid-teardown/commit (it would tear down the views
269
+ // the open is building).
270
+ const mySeq = opLock.nextSeq();
271
+ opLock.takeOwnership(mySeq);
272
+ const release = await opLock.acquire();
273
+ try {
274
+ // Superseded by a newer open/close that arrived while we waited for the
275
+ // lock: that request now owns the runtime, so disposing here would tear
276
+ // down ITS views. Skip — the newer op's own teardown covers the session
277
+ // we meant to close.
278
+ if (!opLock.isOwner(mySeq))
279
+ return;
280
+ // Mark the close in progress BEFORE disposeSession nulls currentSession,
281
+ // so bridge getters refuse to resolve the dying session during the
282
+ // session.close() await (cleared in the finally once views are gone).
283
+ closing = true;
284
+ // Invalidate the active session's onLog BEFORE teardown starts — lines
285
+ // flushed by the dying compile worker must not reach the panel.
286
+ logGeneration++;
287
+ clearSimulatorServicewechatReferer();
288
+ await disposeSession();
289
+ // Record the root being torn down BEFORE clearing it, so a teardown/
290
+ // debounced write already in flight can still be accepted against it.
291
+ if (currentProjectPath !== '')
292
+ lastClosedProjectPath = currentProjectPath;
293
+ currentProjectPath = '';
294
+ ctx.views.disposeAll();
295
+ }
296
+ finally {
297
+ closing = false;
298
+ release();
299
+ }
225
300
  },
226
301
  getSession: () => currentSession,
227
302
  getProjectPath: () => currentProjectPath,
228
303
  getLastClosedProjectPath: () => lastClosedProjectPath,
229
304
  hasActiveSession: () => currentSession !== null,
305
+ isClosing: () => closing,
230
306
  async captureThumbnail(projectPath) {
231
307
  if (!currentSession || projectPath !== currentProjectPath)
232
308
  return null;
233
- const wc = ctx.views.getSimulatorWebContents();
234
- if (!wc)
309
+ const simulatorWc = ctx.views.getSimulatorWebContents();
310
+ if (!simulatorWc)
235
311
  return null;
236
312
  if (ctx.views.getSimulatorProjectPath() !== projectPath)
237
313
  return null;
238
314
  const session = currentSession;
315
+ // Capture the active render-host guest (the mini-program content); the
316
+ // simulator WCV only holds the device-shell chrome. In native-host with no
317
+ // active guest (not mounted / mid page-switch / destroyed) return null
318
+ // rather than persist a device-shell frame as the page — that wrong-content
319
+ // frame is the bug this path exists to avoid. Non-native-host: the sim WCV.
320
+ const bridge = ctx.bridge;
321
+ const nativeHost = bridge?.isNativeHost() ?? false;
322
+ const renderGuest = nativeHost ? (bridge?.getActiveRenderWc() ?? null) : null;
323
+ if (nativeHost && !renderGuest)
324
+ return null;
325
+ const captureTarget = renderGuest ?? simulatorWc;
326
+ // A destroyed target means teardown is in flight (no valid frame) — return
327
+ // null rather than let capturePage() throw indistinguishably into the catch.
328
+ if (captureTarget.isDestroyed())
329
+ return null;
239
330
  try {
240
- const image = await wc.capturePage();
331
+ const image = await captureTarget.capturePage();
241
332
  if (currentSession !== session
242
333
  || currentProjectPath !== projectPath
243
- || ctx.views.getSimulatorWebContents() !== wc
244
- || ctx.views.getSimulatorProjectPath() !== projectPath) {
334
+ || ctx.views.getSimulatorWebContents() !== simulatorWc
335
+ || ctx.views.getSimulatorProjectPath() !== projectPath
336
+ // Captured target must still be the live one: a guest that navigated
337
+ // mid-capture would otherwise persist a frame from the wrong page.
338
+ || (nativeHost ? (bridge?.getActiveRenderWc() ?? null) : simulatorWc) !== captureTarget) {
245
339
  return null;
246
340
  }
247
341
  const dataUrl = `data:image/png;base64,${image.toPNG().toString('base64')}`;
@@ -12,6 +12,10 @@ export declare const serviceHostPreloadPath: string;
12
12
  export interface ServiceHostWindowOptions {
13
13
  bridgeId: string;
14
14
  appId: string;
15
+ /** Current project root. Folded into the session partition (with appId) so a
16
+ * different project declaring the same appId gets isolated storage. Omitted on
17
+ * the pre-warm pool path (no project known yet → shared partition). */
18
+ projectPath?: string;
15
19
  pagePath: string;
16
20
  pkgRoot: string;
17
21
  root?: string;
@@ -59,7 +63,7 @@ export declare function navigateServiceHost(win: BrowserWindow, url: string): Pr
59
63
  * path, which passes the project's appId. Reconciling pooling with per-project
60
64
  * partitions (e.g. per-partition sub-pools) is out of scope for this change.
61
65
  */
62
- export declare function serviceHostSpec(appId?: string): ServiceHostSpec;
66
+ export declare function serviceHostSpec(appId?: string, projectPath?: string): ServiceHostSpec;
63
67
  /**
64
68
  * Construct + navigate a fresh service-host window (the non-pool path).
65
69
  * Behavior-identical to the pre-refactor implementation.
@@ -110,12 +110,15 @@ export function navigateServiceHost(win, url) {
110
110
  * path, which passes the project's appId. Reconciling pooling with per-project
111
111
  * partitions (e.g. per-partition sub-pools) is out of scope for this change.
112
112
  */
113
- export function serviceHostSpec(appId) {
113
+ export function serviceHostSpec(appId, projectPath) {
114
114
  return {
115
115
  // Per-project partition when an appId is known (so this project's service
116
116
  // host shares storage ONLY with its own render side); the shared partition
117
117
  // for the pre-warm pool's default spec (no appId — see KNOWN BLOCKER below).
118
- partition: appId ? miniappPartition(appId) : SERVICE_HOST_PARTITION,
118
+ // projectPath joins the key so same-appId/different-path projects isolate;
119
+ // it must match the simulator WCV's partition (view-manager passes the same
120
+ // (appId, projectPath)).
121
+ partition: appId ? miniappPartition(appId, projectPath) : SERVICE_HOST_PARTITION,
119
122
  preloadPath: serviceHostPreloadPath,
120
123
  size: { width: 980, height: 720 },
121
124
  contextIsolation: false,
@@ -132,7 +135,7 @@ export function createServiceHostWindow(opts) {
132
135
  // Default (non-pooled) path: pin the service host to THIS project's partition
133
136
  // so its logic-layer localStorage/cookies are shared with the project's render
134
137
  // side only, never with other projects.
135
- const win = constructServiceHostWindow({ appId: opts.appId, partition: miniappPartition(opts.appId) });
138
+ const win = constructServiceHostWindow({ appId: opts.appId, partition: miniappPartition(opts.appId, opts.projectPath) });
136
139
  void navigateServiceHost(win, buildServiceHostSpawnUrl(opts));
137
140
  return win;
138
141
  }