@ifc-lite/viewer 1.23.0 → 1.25.0

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 (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -0,0 +1,514 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `ExtensionHostService` — viewer-side façade that composes the
7
+ * `@ifc-lite/extensions` building blocks into a single coordinated
8
+ * service.
9
+ *
10
+ * storage IDB-backed persistence
11
+ * slotRegistry In-memory pub/sub for UI contributions
12
+ * dispatcher Activation event dispatcher
13
+ * runtime Per-extension sandbox lifecycle
14
+ * audit Append-only audit log
15
+ * loader Glue that reads storage + registers contributions
16
+ *
17
+ * The host service is the single object the React layer consumes via
18
+ * `<ExtensionHostProvider>`. It exposes high-level operations
19
+ * (install, uninstall, enable/disable, listExtensions, importBundle,
20
+ * exportBundle, subscribeSlot) so UI code never reaches into the
21
+ * underlying primitives directly.
22
+ *
23
+ * Lifecycle:
24
+ * 1. Construct with a `BimContext`.
25
+ * 2. Call `init()` once at app startup. It loads installed bundles,
26
+ * validates them, registers contributions, and fires `onStartup`.
27
+ * 3. Call `installFromBytes(bytes, grants)` when the user imports a
28
+ * `.iflx` file. The capability review screen calls this after
29
+ * the user approves.
30
+ * 4. Call `uninstall(id)` to remove.
31
+ */
32
+
33
+ import {
34
+ ActionLog,
35
+ ActivationDispatcher,
36
+ AuditLog,
37
+ CANONICAL_FIXTURES,
38
+ ExtensionLoader,
39
+ ExtensionRuntime,
40
+ IdleMineScheduler,
41
+ SlotRegistry,
42
+ filterAgainstInstalled,
43
+ parseCapabilities,
44
+ planFromPattern,
45
+ revalidateAgainstSdk,
46
+ runBundleTests,
47
+ syntheticFixtureLoader,
48
+ type ActionIntent,
49
+ type ActionParams,
50
+ type AuthoringPlan,
51
+ type InstalledExtensionRecord,
52
+ type LoadedExtensionStatus,
53
+ type MinedPattern,
54
+ type MineEvent,
55
+ type RevalidationSummary,
56
+ type RuntimeRunResult,
57
+ type SlotContribution,
58
+ type SlotListener,
59
+ type TestRunSummary,
60
+ type ValidationResult,
61
+ } from '@ifc-lite/extensions';
62
+ import type { BimContext } from '@ifc-lite/sdk';
63
+ import { IdbExtensionStorage } from './idb-storage.js';
64
+ import { IdbLogStorage } from './idb-log-storage.js';
65
+ import { createBimSandboxFactory } from './sandbox-factory.js';
66
+ import { FlavorService } from './flavor-service.js';
67
+ import { runExtensionCommand } from './host-commands.js';
68
+ import {
69
+ ExtensionInstallError,
70
+ installFromBytes,
71
+ previewBundleBytes,
72
+ setEnabled,
73
+ uninstall,
74
+ type ExtensionInstallSummary,
75
+ } from './host-installer.js';
76
+
77
+ export { ExtensionInstallError } from './host-installer.js';
78
+ export type { ExtensionInstallSummary } from './host-installer.js';
79
+
80
+ export interface ExtensionHostServiceOptions {
81
+ sdk: BimContext;
82
+ }
83
+
84
+ export class ExtensionHostService {
85
+ readonly storage = new IdbExtensionStorage();
86
+ readonly slotRegistry = new SlotRegistry();
87
+ readonly dispatcher = new ActivationDispatcher();
88
+ readonly audit = new AuditLog();
89
+ readonly flavors = new FlavorService({
90
+ // Mirror flavor lifecycle into the action log so the miner sees
91
+ // activate/export/import patterns. Content-free — only the id.
92
+ onLifecycle: (event, id) => {
93
+ if (event === 'activate') this.emitAction('flavor.activate', { id: id ?? '' });
94
+ else if (event === 'export') this.emitAction('flavor.export', {});
95
+ else if (event === 'import') this.emitAction('flavor.import', {});
96
+ },
97
+ });
98
+ readonly actionLog = new ActionLog();
99
+ private readonly logStorage = new IdbLogStorage();
100
+ readonly miner: IdleMineScheduler;
101
+ readonly runtime: ExtensionRuntime;
102
+ readonly loader: ExtensionLoader;
103
+ private suggestions: MineEvent | undefined;
104
+ private suggestionListeners = new Set<(event: MineEvent) => void>();
105
+ readonly sdk: BimContext;
106
+
107
+ private initialized = false;
108
+ private listeners = new Set<() => void>();
109
+
110
+ constructor(opts: ExtensionHostServiceOptions) {
111
+ this.sdk = opts.sdk;
112
+ this.runtime = new ExtensionRuntime({
113
+ factory: createBimSandboxFactory({ sdk: opts.sdk }),
114
+ sdk: opts.sdk,
115
+ // Spec defaults per RFC §02.5: 64 MiB heap, 5 s sync CPU, 1 MiB
116
+ // stack. The sandbox enforces these via QuickJS setMemoryLimit /
117
+ // setMaxStackSize / setInterruptHandler. Tighter dry-run budgets
118
+ // come from `buildDryRunBudget` when the authoring loop spins up
119
+ // a transient runtime.
120
+ defaultLimits: {
121
+ memoryBytes: 64 * 1024 * 1024,
122
+ timeoutMs: 5_000,
123
+ maxStackBytes: 1 * 1024 * 1024,
124
+ },
125
+ });
126
+ this.loader = new ExtensionLoader({
127
+ storage: this.storage,
128
+ registry: this.slotRegistry,
129
+ dispatcher: this.dispatcher,
130
+ });
131
+
132
+ this.miner = new IdleMineScheduler();
133
+ // Pipe every logged action into the miner so the idle timer
134
+ // re-arms as the user works. Also queue for IDB persistence so
135
+ // the log survives reload.
136
+ this.actionLog.subscribe((event) => {
137
+ this.miner.push(event);
138
+ this.logStorage.appendAction(event);
139
+ });
140
+ // Mirror audit events to IDB so the audit history survives a
141
+ // reload too — otherwise "Audit log" misleads users.
142
+ this.audit.subscribe((event) => {
143
+ this.logStorage.appendAudit(event);
144
+ });
145
+ // When the miner fires, filter against currently-installed tools
146
+ // and notify listeners. The filter call is async (it reads
147
+ // storage); store the most recent event so late subscribers can
148
+ // still see the latest suggestions.
149
+ this.miner.subscribe((event) => {
150
+ void this.handleMineEvent(event);
151
+ });
152
+
153
+ this.dispatcher.onActivate(async (id) => {
154
+ const record = await this.storage.getExtension(id);
155
+ if (!record) return;
156
+ const grants = parseCapabilities(record.grantedCapabilities);
157
+ if (!grants.ok) {
158
+ console.warn(`[ext-host] Skipping activation of ${id}: invalid stored capabilities.`);
159
+ return;
160
+ }
161
+ const bundle = this.loader.getBundle(id);
162
+ try {
163
+ await this.runtime.activate(id, grants.value, bundle);
164
+ this.audit.append({
165
+ kind: 'activate',
166
+ extensionId: id,
167
+ version: record.version,
168
+ });
169
+ } catch (err) {
170
+ console.error(`[ext-host] Activation of ${id} failed:`, err);
171
+ this.audit.append({
172
+ kind: 'unhealthy',
173
+ extensionId: id,
174
+ version: record.version,
175
+ reason: err instanceof Error ? err.message : String(err),
176
+ });
177
+ }
178
+ });
179
+ }
180
+
181
+ async init(): Promise<LoadedExtensionStatus[]> {
182
+ if (this.initialized) return [];
183
+ // Only set initialized after startup succeeds — otherwise a failed
184
+ // loadAll() / fire() leaves the service stuck and later init()
185
+ // calls return [] without actually loading anything.
186
+ try {
187
+ // Hydrate logs from IDB before any new events fire so the
188
+ // resumed seq counters start where we left off. Errors here are
189
+ // non-fatal — we just continue with empty in-memory logs.
190
+ try {
191
+ const [priorActions, priorAudit] = await Promise.all([
192
+ this.logStorage.loadActions(),
193
+ this.logStorage.loadAudit(),
194
+ ]);
195
+ this.actionLog.hydrate(priorActions);
196
+ this.audit.hydrate(priorAudit);
197
+ } catch (err) {
198
+ console.warn('[ext-host] log hydration failed; starting empty:', err);
199
+ }
200
+
201
+ const statuses = await this.loader.loadAll();
202
+ // Seed a baseline flavor on first run so the Flavors dialog
203
+ // never opens to an empty state. Existing users keep whatever
204
+ // they had — we only create the Default if the library is empty.
205
+ try {
206
+ const existing = await this.flavors.list();
207
+ if (existing.length === 0) {
208
+ await this.flavors.resetToDefaults();
209
+ } else {
210
+ // Make sure SOMETHING is active so Capture / activate UI has
211
+ // a target. Falls back to the first flavor if no pointer.
212
+ const active = await this.flavors.getActive();
213
+ if (!active) await this.flavors.activate(existing[0].id);
214
+ }
215
+ } catch (err) {
216
+ console.warn('[ext-host] baseline flavor seed failed:', err);
217
+ }
218
+ await this.dispatcher.fire('onStartup');
219
+ this.initialized = true;
220
+ this.emit();
221
+ return statuses;
222
+ } catch (err) {
223
+ this.initialized = false;
224
+ throw err;
225
+ }
226
+ }
227
+
228
+ /** Inspect a `.iflx` byte string without installing it. */
229
+ previewBundle(bytes: Uint8Array): Promise<ValidationResult<ExtensionInstallSummary>> {
230
+ return previewBundleBytes(bytes);
231
+ }
232
+
233
+ /**
234
+ * Install a previewed bundle. Delegates to the lifecycle helpers in
235
+ * `host-installer.ts`. `grantedCapabilities` is the user-approved
236
+ * subset of `bundle.manifest.capabilities` from the review screen.
237
+ */
238
+ installFromBytes(
239
+ bytes: Uint8Array,
240
+ grantedCapabilities: string[],
241
+ ): Promise<LoadedExtensionStatus> {
242
+ return installFromBytes(this.installerDeps(), bytes, grantedCapabilities);
243
+ }
244
+
245
+ /** Uninstall an extension and remove its bundle. */
246
+ uninstall(id: string): Promise<void> {
247
+ return uninstall(this.installerDeps(), id);
248
+ }
249
+
250
+ /** Enable/disable without uninstalling. */
251
+ setEnabled(id: string, enabled: boolean): Promise<void> {
252
+ return setEnabled(this.installerDeps(), id, enabled);
253
+ }
254
+
255
+ /** Bundle the host primitives the installer needs. */
256
+ private installerDeps() {
257
+ return {
258
+ storage: this.storage,
259
+ runtime: this.runtime,
260
+ loader: this.loader,
261
+ dispatcher: this.dispatcher,
262
+ audit: this.audit,
263
+ emitAction: <K extends ActionIntent>(intent: K, params: ActionParams[K]) => this.emitAction(intent, params),
264
+ emit: () => this.emit(),
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Dispatch an extension command. Finds the owning extension,
270
+ * activates it if needed, loads the handler source from the bundle,
271
+ * wraps it, injects `__ifclite_ctx__`, and runs.
272
+ *
273
+ * Implementation lives in `host-commands.ts` — this method is a
274
+ * thin delegator that injects the host's primitives.
275
+ */
276
+ runCommand(commandId: string): Promise<RuntimeRunResult | undefined> {
277
+ return runExtensionCommand(
278
+ {
279
+ storage: this.storage,
280
+ loader: this.loader,
281
+ runtime: this.runtime,
282
+ dispatcher: this.dispatcher,
283
+ sdk: this.sdk,
284
+ },
285
+ commandId,
286
+ );
287
+ }
288
+
289
+ /** Read the current install state (storage snapshot). */
290
+ async listInstalled(): Promise<InstalledExtensionRecord[]> {
291
+ return this.storage.listExtensions();
292
+ }
293
+
294
+ /** Subscribe to a slot. Forwards to the underlying registry. */
295
+ subscribeSlot<T = unknown>(slot: string, listener: SlotListener<T>): () => void {
296
+ return this.slotRegistry.subscribe(slot, listener);
297
+ }
298
+
299
+ getSlotContributions<T = unknown>(slot: string): SlotContribution<T>[] {
300
+ return this.slotRegistry.getAll<T>(slot);
301
+ }
302
+
303
+ /** Subscribe to "anything changed" pulses for UI state. */
304
+ onChange(listener: () => void): () => void {
305
+ this.listeners.add(listener);
306
+ return () => this.listeners.delete(listener);
307
+ }
308
+
309
+ /**
310
+ * Log a user-level action. Viewer slices call this from their
311
+ * reducers / sagas so the action log mirrors the user's intents
312
+ * for the pattern miner.
313
+ *
314
+ * Content-free metadata only — see `ActionParams` for the schema.
315
+ */
316
+ emitAction<K extends ActionIntent>(intent: K, params: ActionParams[K]): void {
317
+ this.actionLog.append({ intent, params });
318
+ }
319
+
320
+ /** Subscribe to mine results (already filtered against installs). */
321
+ onSuggestions(listener: (event: MineEvent) => void): () => void {
322
+ this.suggestionListeners.add(listener);
323
+ // Surface the most recent event immediately so late subscribers
324
+ // (panels that mount after a mine fired) don't miss it.
325
+ if (this.suggestions) {
326
+ try {
327
+ listener(this.suggestions);
328
+ } catch (err) {
329
+ console.error('[ext-host] Suggestion listener threw on replay:', err);
330
+ }
331
+ }
332
+ return () => {
333
+ this.suggestionListeners.delete(listener);
334
+ };
335
+ }
336
+
337
+ /** Read the last mine result, if any. */
338
+ getSuggestions(): MineEvent | undefined {
339
+ return this.suggestions;
340
+ }
341
+
342
+ /** Wipe the IDB-persisted action log. Pairs with `actionLog.clear()`. */
343
+ async clearPersistedActionLog(): Promise<void> {
344
+ await this.logStorage.clearActions();
345
+ }
346
+
347
+ /** Wipe the IDB-persisted audit log. Pairs with `audit.clear()`. */
348
+ async clearPersistedAuditLog(): Promise<void> {
349
+ await this.logStorage.clearAudit();
350
+ }
351
+
352
+ /**
353
+ * Build an `AuthoringPlan` stub from a mined pattern. The UI hands
354
+ * the stub off to the chat panel as the seed for an authoring turn.
355
+ */
356
+ acceptSuggestion(pattern: MinedPattern): AuthoringPlan {
357
+ return planFromPattern(pattern);
358
+ }
359
+
360
+ /**
361
+ * Run an installed extension's declared tests against its bundle.
362
+ * Throws if the extension is not installed or its bundle is missing.
363
+ */
364
+ async runTests(id: string): Promise<TestRunSummary> {
365
+ const record = await this.storage.getExtension(id);
366
+ if (!record) throw new Error(`No installed extension with id "${id}".`);
367
+ const bundle = this.loader.getBundle(id);
368
+ if (!bundle) throw new Error(`Bundle for ${id} not loaded.`);
369
+ const grants = parseCapabilities(record.grantedCapabilities);
370
+ if (!grants.ok) {
371
+ throw new Error(`Stored capabilities for ${id} are invalid.`);
372
+ }
373
+ return runBundleTests({
374
+ runtime: this.runtime,
375
+ bundle,
376
+ grants: grants.value,
377
+ // Plug the canonical synthetic fixtures so tests declaring
378
+ // `fixture: "residential-small"` get a working ctx.bim. Hosts
379
+ // that ship their own fixture loader can override via a
380
+ // custom factory.
381
+ loadFixture: syntheticFixtureLoader(CANONICAL_FIXTURES),
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Switch to the named flavor, enabling its declared extensions and
387
+ * disabling anything the previous flavor had that this one doesn't.
388
+ * Returns the structured switch result so the UI can surface
389
+ * failures inline.
390
+ */
391
+ async switchFlavor(targetId: string): Promise<void> {
392
+ const flavors = await this.flavors.list();
393
+ const target = flavors.find((f) => f.id === targetId);
394
+ if (!target) throw new Error(`Unknown flavor: ${targetId}`);
395
+ const records = await this.storage.listExtensions();
396
+ const installed = records.map((r) => ({ id: r.id, enabled: r.enabled }));
397
+
398
+ const result = await this.flavors.switchTo(target, installed, {
399
+ setEnabled: async (id, enabled) => {
400
+ await this.setEnabled(id, enabled);
401
+ },
402
+ deactivate: async (id) => {
403
+ await this.runtime.deactivate(id);
404
+ await this.loader.unload(id);
405
+ },
406
+ reload: async (id) => {
407
+ const status = await this.loader.load(id);
408
+ return !!status?.ok;
409
+ },
410
+ setActiveFlavor: async (id) => {
411
+ await this.flavors.activate(id);
412
+ },
413
+ });
414
+
415
+ if (!result.ok) {
416
+ throw new Error(
417
+ `Flavor switch failed for: ${result.failures.join(', ')}`,
418
+ );
419
+ }
420
+ // Roundtrip viewer-store state from the flavor snapshot. Without
421
+ // this, activating a flavor only toggles extensions — saved lenses
422
+ // stay whatever the previous flavor left behind, so switching feels
423
+ // like a no-op. The lens definition was stored opaquely on capture;
424
+ // we cast it back to the viewer's Lens shape since both ends agree
425
+ // on the schema (FlavorDialog.handleCaptureCurrent put it in).
426
+ try {
427
+ const lenses = target.lenses
428
+ .map((entry) => entry.definition as unknown)
429
+ .filter((d): d is import('@ifc-lite/lens').Lens =>
430
+ !!d && typeof d === 'object' && 'id' in d && 'rules' in d,
431
+ );
432
+ // Late import keeps the host service free of UI store deps for
433
+ // headless test environments — only the browser viewer wires it.
434
+ const { useViewerStore } = await import('@/store');
435
+ useViewerStore.getState().setSavedLenses(lenses);
436
+ } catch (err) {
437
+ console.warn('[ext-host] lens restore on switch failed:', err);
438
+ }
439
+ this.emit();
440
+ }
441
+
442
+ /**
443
+ * Re-run every installed extension's tests against the supplied SDK
444
+ * version. The result feeds the repair queue UI: outdated or
445
+ * permissive ranges with failing tests land in `needsRepair`.
446
+ */
447
+ async revalidateForSdk(sdkVersion: string): Promise<RevalidationSummary> {
448
+ const records = await this.storage.listExtensions();
449
+ const installed = records.map((rec) => {
450
+ const grants = parseCapabilities(rec.grantedCapabilities);
451
+ const bundle = this.loader.getBundle(rec.id);
452
+ return {
453
+ id: rec.id,
454
+ engines: { ifcLiteSdk: bundle?.manifest.engines.ifcLiteSdk ?? '*' },
455
+ grants: grants.ok ? grants.value : [],
456
+ };
457
+ });
458
+ return revalidateAgainstSdk({
459
+ sdk: sdkVersion,
460
+ installed,
461
+ resolveBundle: (id) => this.loader.getBundle(id),
462
+ runtime: this.runtime,
463
+ });
464
+ }
465
+
466
+ /** Tear down everything. Called on flavor switch / sign-out. */
467
+ async dispose(): Promise<void> {
468
+ this.miner.dispose();
469
+ this.suggestionListeners.clear();
470
+ this.suggestions = undefined;
471
+ // Flush debounced log writes before teardown so events from the
472
+ // last ~250 ms aren't lost and the debounce timers don't leak.
473
+ await this.logStorage.flush();
474
+ await this.runtime.disposeAll();
475
+ for (const id of this.dispatcher.listExtensions()) {
476
+ this.dispatcher.unregister(id);
477
+ }
478
+ this.slotRegistry.clear();
479
+ this.listeners.clear();
480
+ this.initialized = false;
481
+ }
482
+
483
+ private async handleMineEvent(event: MineEvent): Promise<void> {
484
+ let filtered = event.patterns;
485
+ try {
486
+ const installed = await this.storage.listExtensions();
487
+ filtered = filterAgainstInstalled(
488
+ event.patterns,
489
+ installed.map((ext) => ({
490
+ id: ext.id,
491
+ grantedCapabilities: ext.grantedCapabilities,
492
+ })),
493
+ );
494
+ } catch (err) {
495
+ // If storage fails we still surface the unfiltered patterns —
496
+ // worst case the user sees a "you already have this" duplicate.
497
+ console.warn('[ext-host] filterAgainstInstalled failed; surfacing raw patterns:', err);
498
+ }
499
+ const next: MineEvent = { ...event, patterns: filtered };
500
+ this.suggestions = next;
501
+ for (const listener of this.suggestionListeners) {
502
+ try {
503
+ listener(next);
504
+ } catch (err) {
505
+ console.error('[ext-host] Suggestion listener threw:', err);
506
+ }
507
+ }
508
+ }
509
+
510
+ private emit(): void {
511
+ for (const listener of this.listeners) listener();
512
+ }
513
+ }
514
+
@@ -0,0 +1,140 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ // fake-indexeddb installs a Node-compatible IDB implementation on
6
+ // `globalThis.indexedDB` when imported via the `/auto` entry point.
7
+ import 'fake-indexeddb/auto';
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { beforeEach, describe, it } from 'node:test';
11
+ import type { Flavor } from '@ifc-lite/extensions';
12
+ import { IdbFlavorStorage } from './idb-flavor-storage.js';
13
+
14
+ function baseFlavor(id: string, name: string = id): Flavor {
15
+ return {
16
+ schemaVersion: 1,
17
+ id,
18
+ name,
19
+ description: '',
20
+ createdAt: '2026-01-01T00:00:00Z',
21
+ updatedAt: '2026-01-01T00:00:00Z',
22
+ extensions: [],
23
+ lenses: [],
24
+ savedQueries: [],
25
+ keybindings: [],
26
+ layout: { state: {} },
27
+ settings: {},
28
+ };
29
+ }
30
+
31
+ async function resetDb(): Promise<void> {
32
+ // Open + clear the storage between tests so each runs against a
33
+ // clean slate. fake-indexeddb persists in-memory across imports
34
+ // within a single process; clear() wipes the three stores.
35
+ const store = new IdbFlavorStorage();
36
+ await store.clear();
37
+ }
38
+
39
+ describe('IdbFlavorStorage', () => {
40
+ beforeEach(async () => {
41
+ await resetDb();
42
+ });
43
+
44
+ it('round-trips a flavor through put/get/list/delete', async () => {
45
+ const store = new IdbFlavorStorage();
46
+ const flv = baseFlavor('flv.a', 'A');
47
+ await store.putFlavor(flv);
48
+
49
+ const got = await store.getFlavor('flv.a');
50
+ assert.ok(got);
51
+ assert.strictEqual(got.id, 'flv.a');
52
+ assert.strictEqual(got.name, 'A');
53
+
54
+ const list = await store.listFlavors();
55
+ assert.strictEqual(list.length, 1);
56
+ assert.strictEqual(list[0].id, 'flv.a');
57
+
58
+ await store.deleteFlavor('flv.a');
59
+ assert.strictEqual(await store.getFlavor('flv.a'), undefined);
60
+ assert.strictEqual((await store.listFlavors()).length, 0);
61
+ });
62
+
63
+ it('returns undefined for unknown flavor', async () => {
64
+ const store = new IdbFlavorStorage();
65
+ assert.strictEqual(await store.getFlavor('flv.missing'), undefined);
66
+ });
67
+
68
+ it('stores and reads the active-flavor pointer', async () => {
69
+ const store = new IdbFlavorStorage();
70
+ assert.strictEqual(await store.getActiveId(), undefined);
71
+ await store.setActiveId('flv.a');
72
+ assert.strictEqual(await store.getActiveId(), 'flv.a');
73
+ await store.setActiveId(undefined);
74
+ assert.strictEqual(await store.getActiveId(), undefined);
75
+ });
76
+
77
+ it('clears the active pointer when the active flavor is deleted', async () => {
78
+ const store = new IdbFlavorStorage();
79
+ await store.putFlavor(baseFlavor('flv.a'));
80
+ await store.setActiveId('flv.a');
81
+ await store.deleteFlavor('flv.a');
82
+ assert.strictEqual(await store.getActiveId(), undefined);
83
+ });
84
+
85
+ it('captures a snapshot when overwriting a flavor', async () => {
86
+ const store = new IdbFlavorStorage();
87
+ await store.putFlavor(baseFlavor('flv.a', 'first'));
88
+ await store.putFlavor(baseFlavor('flv.a', 'second'), 'rename');
89
+ const snaps = await store.listSnapshots('flv.a');
90
+ assert.strictEqual(snaps.length, 1);
91
+ assert.strictEqual(snaps[0].flavor.name, 'first');
92
+ assert.strictEqual(snaps[0].reason, 'rename');
93
+ });
94
+
95
+ it('caps snapshot retention at 10 per flavor', async () => {
96
+ const store = new IdbFlavorStorage();
97
+ for (let i = 0; i < 15; i++) {
98
+ await store.putFlavor(baseFlavor('flv.a', `v${i}`));
99
+ }
100
+ const snaps = await store.listSnapshots('flv.a');
101
+ assert.ok(snaps.length <= 10, `expected ≤10 snapshots, got ${snaps.length}`);
102
+ // Newest first.
103
+ assert.strictEqual(snaps[0].flavor.name, 'v13');
104
+ });
105
+
106
+ it('restoreSnapshot writes the snapshot back as the current flavor', async () => {
107
+ const store = new IdbFlavorStorage();
108
+ await store.putFlavor(baseFlavor('flv.a', 'v1'));
109
+ await store.putFlavor(baseFlavor('flv.a', 'v2'));
110
+ const snaps = await store.listSnapshots('flv.a');
111
+ assert.strictEqual(snaps[0].flavor.name, 'v1');
112
+ const restored = await store.restoreSnapshot('flv.a', snaps[0].seq, 'rollback');
113
+ assert.ok(restored);
114
+ const current = await store.getFlavor('flv.a');
115
+ assert.strictEqual(current?.name, 'v1');
116
+ });
117
+
118
+ it('cascade-deletes snapshots when a flavor is deleted', async () => {
119
+ const store = new IdbFlavorStorage();
120
+ await store.putFlavor(baseFlavor('flv.a', 'v1'));
121
+ await store.putFlavor(baseFlavor('flv.a', 'v2'));
122
+ await store.deleteFlavor('flv.a');
123
+ const snaps = await store.listSnapshots('flv.a');
124
+ assert.strictEqual(snaps.length, 0);
125
+ });
126
+
127
+ it('listSnapshots scopes by flavor id', async () => {
128
+ const store = new IdbFlavorStorage();
129
+ await store.putFlavor(baseFlavor('flv.a', 'a1'));
130
+ await store.putFlavor(baseFlavor('flv.a', 'a2'));
131
+ await store.putFlavor(baseFlavor('flv.b', 'b1'));
132
+ await store.putFlavor(baseFlavor('flv.b', 'b2'));
133
+ const a = await store.listSnapshots('flv.a');
134
+ const b = await store.listSnapshots('flv.b');
135
+ assert.strictEqual(a.length, 1);
136
+ assert.strictEqual(b.length, 1);
137
+ assert.strictEqual(a[0].flavor.name, 'a1');
138
+ assert.strictEqual(b[0].flavor.name, 'b1');
139
+ });
140
+ });