@3sln/trove 0.0.12 → 0.0.13

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 (89) hide show
  1. package/README.md +13 -1
  2. package/package.json +2 -1
  3. package/packages/core/src/collections/index.js +71 -10
  4. package/packages/core/src/encryption/keys.js +4 -3
  5. package/packages/core/src/encryption/policy.js +14 -7
  6. package/packages/core/src/encryption/rotation.js +46 -12
  7. package/packages/core/src/errors.js +8 -0
  8. package/packages/core/src/index.js +8 -2
  9. package/packages/core/src/indexers/contribution.js +18 -17
  10. package/packages/core/src/indexing.js +8 -4
  11. package/packages/core/src/issues.js +8 -0
  12. package/packages/core/src/links.js +21 -9
  13. package/packages/core/src/metadata/interface.js +22 -0
  14. package/packages/core/src/metadata/memory.js +15 -0
  15. package/packages/core/src/metadata/sqlite.js +23 -1
  16. package/packages/core/src/plugins/contributions.js +13 -0
  17. package/packages/core/src/rateLimit.js +253 -0
  18. package/packages/core/src/scan.js +44 -16
  19. package/packages/core/src/sidecar/document.js +18 -14
  20. package/packages/core/src/sidecar/index.js +9 -10
  21. package/packages/core/src/sidecar/manager.js +10 -15
  22. package/packages/core/src/uploads.js +40 -15
  23. package/packages/core/src/vfs.js +59 -34
  24. package/packages/plugin-sdk/src/browser.js +155 -0
  25. package/packages/plugin-sdk/src/protocol.js +13 -1
  26. package/packages/server/src/adapters/bun.js +1 -2
  27. package/packages/server/src/adapters/node.js +1 -2
  28. package/packages/server/src/engine/index.js +1 -1
  29. package/packages/server/src/engine/providers/access.js +17 -34
  30. package/packages/server/src/engine/providers/core.js +112 -14
  31. package/packages/server/src/index.js +145 -103
  32. package/packages/server/src/mcp/index.js +7 -12
  33. package/packages/server/src/mcp/tools.js +24 -29
  34. package/packages/server/src/router.js +31 -8
  35. package/packages/server/src/routes.js +74 -113
  36. package/packages/server/src/scope.js +78 -0
  37. package/packages/web/dist/assets/main-828yzsr7.js +511 -0
  38. package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
  39. package/packages/web/dist/index.html +1 -1
  40. package/packages/web/dist/sw.js +1 -1
  41. package/packages/web/src/bl/actions.js +226 -104
  42. package/packages/web/src/bl/activity.js +32 -82
  43. package/packages/web/src/bl/index.js +23 -11
  44. package/packages/web/src/bl/launcher.js +2 -9
  45. package/packages/web/src/bl/links.js +31 -18
  46. package/packages/web/src/bl/offline.js +24 -10
  47. package/packages/web/src/bl/pluginInstall.js +29 -26
  48. package/packages/web/src/bl/queries.js +76 -68
  49. package/packages/web/src/bl/services.js +22 -14
  50. package/packages/web/src/bl/social.js +39 -25
  51. package/packages/web/src/bl/state.js +22 -4
  52. package/packages/web/src/bl/viewState.js +9 -0
  53. package/packages/web/src/bl/watchQuery.js +1 -8
  54. package/packages/web/src/dispatch.js +26 -0
  55. package/packages/web/src/platform/api.js +32 -0
  56. package/packages/web/src/platform/commands.js +14 -15
  57. package/packages/web/src/platform/context.js +3 -58
  58. package/packages/web/src/platform/contributions.js +1 -35
  59. package/packages/web/src/platform/fileChunks.js +281 -0
  60. package/packages/web/src/platform/index.js +8 -9
  61. package/packages/web/src/platform/mediaUrls.js +10 -0
  62. package/packages/web/src/platform/navigation.js +35 -21
  63. package/packages/web/src/platform/pickers.js +39 -19
  64. package/packages/web/src/platform/pluginHost.js +4 -1
  65. package/packages/web/src/platform/pluginRpc.js +47 -2
  66. package/packages/web/src/platform/spatialNav.js +1 -1
  67. package/packages/web/src/platform/viewport.js +14 -6
  68. package/packages/web/src/platform/voiceSearch.js +17 -9
  69. package/packages/web/src/platform/whenclause.js +9 -19
  70. package/packages/web/src/runtime.js +2 -37
  71. package/packages/web/src/ui/activate.js +18 -2
  72. package/packages/web/src/ui/components/activityPanel.js +1 -1
  73. package/packages/web/src/ui/components/adminView.js +4 -7
  74. package/packages/web/src/ui/components/editorArea.js +10 -1
  75. package/packages/web/src/ui/components/launcher.js +29 -13
  76. package/packages/web/src/ui/components/openers/index.js +17 -22
  77. package/packages/web/src/ui/components/openers/markdown.js +2 -2
  78. package/packages/web/src/ui/components/overlays.js +31 -5
  79. package/packages/web/src/ui/components/pluginReview.js +7 -4
  80. package/packages/web/src/ui/components/pluginsView.js +1 -1
  81. package/packages/web/src/ui/components/settingsView.js +30 -27
  82. package/packages/web/src/ui/components/statusBar.js +8 -6
  83. package/packages/web/src/ui/components/views/grid.js +2 -2
  84. package/packages/web/src/ui/components/views/parts.js +1 -1
  85. package/packages/web/src/ui/compositions/workbench.js +20 -6
  86. package/packages/web/src/ui/sanitize.js +0 -5
  87. package/packages/web/src/workbench.js +37 -19
  88. package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
  89. package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
@@ -32,10 +32,12 @@ const ACTIVE_MS = 1000; // while something is running, we want a live-feeling ba
32
32
  const IDLE_MS = 60_000; // otherwise just enough to notice a task someone else started
33
33
 
34
34
  export class ActivityService {
35
+ #state;
36
+
35
37
  constructor(platform) {
36
38
  this.platform = platform;
37
39
  this.api = platform.api;
38
- this.state = {
40
+ this.#state = {
39
41
  tasks: [], // merged: local first, then the server mirror
40
42
  issues: [],
41
43
  issuesLoading: false,
@@ -46,21 +48,32 @@ export class ActivityService {
46
48
  // produced.
47
49
  tasksError: null,
48
50
  issuesError: null,
49
- open: false, // the activity panel
50
51
  };
51
- this.cell = cell(this.state);
52
+ this.cell = cell(this.#state);
52
53
  this._local = new Map(); // id -> local task (this browser owns these)
53
54
  this._server = [];
54
55
  this._timer = null;
55
56
  this._pollMs = null;
56
57
  }
57
58
 
59
+ /**
60
+ * The value, for whoever is about to decide something from it.
61
+ *
62
+ * The same door every slice offers. Actions read `.state` and queries read `.cell`, and
63
+ * the two are only equal by habit — bl/state.js says so, and says a resource has one
64
+ * value and one way to read it. `state` is also a public field on a service, which is one
65
+ * typo from `social.state.sidecar = null` bypassing the cell and notifying nothing.
66
+ */
67
+ get() {
68
+ return this.#state;
69
+ }
70
+
58
71
  observe() {
59
72
  return this.cell;
60
73
  }
61
74
  #set(patch) {
62
- this.state = { ...this.state, ...patch };
63
- this.cell.setValue(this.state);
75
+ this.#state = { ...this.#state, ...patch };
76
+ this.cell.setValue(this.#state);
64
77
  }
65
78
  #merge() {
66
79
  this.#set({ tasks: [...this._local.values(), ...this._server] });
@@ -211,103 +224,40 @@ export class ActivityService {
211
224
  // running→idle transition to notice and the fixed problem would sit on screen
212
225
  // until the next idle poll. A couple of bounded follow-ups cover that window
213
226
  // without turning this into a busy loop.
214
- this.#followUp();
215
- this.platform.notifications?.info?.('Retrying — watch its progress in Activity');
227
+ this.followUp();
228
+ this.platform.notifications.info('Retrying — watch its progress in Activity');
216
229
  } catch (err) {
217
- this.platform.notifications?.error?.(`Couldn't retry: ${err.message}`);
230
+ this.platform.notifications.error(`Couldn't retry: ${err.message}`);
218
231
  }
219
232
  }
220
233
 
221
234
  async dismissIssue(id) {
222
- const before = this.state.issues;
235
+ const before = this.#state.issues;
223
236
  this.#set({ issues: before.filter((i) => i.id !== id) });
224
237
  try {
225
238
  await this.api.dismissIssue(id);
226
239
  } catch (err) {
227
240
  this.#set({ issues: before }); // put it back rather than pretend
228
- this.platform.notifications?.error?.(`Couldn't dismiss: ${err.message}`);
229
- }
230
- }
231
-
232
- /** Two bounded re-reads after an action that may have resolved something. */
233
- #followUp() {
234
- for (const ms of [400, 1500]) {
235
- const t = setTimeout(() => { this.refreshTasks(); this.refreshIssues(); }, ms);
236
- t?.unref?.();
237
- }
238
- }
239
-
240
- /** Rebuild the whole search index. Surfaced as a command; reports as a task. */
241
- async rebuildIndex() {
242
- try {
243
- const res = await this.api.reindex();
244
- await this.refreshTasks();
245
- this.togglePanel(true);
246
- if (res.alreadyRunning) this.platform.notifications?.info?.('A rebuild is already running');
247
- return res;
248
- } catch (err) {
249
- this.platform.notifications?.error?.(`Couldn't rebuild the index: ${err.message}`);
250
- throw err;
251
- }
252
- }
253
-
254
- /**
255
- * Reconcile a collection with its object store — the way a drive notices files added,
256
- * replaced, or removed by anything that isn't Trove. Reports as a task, like a rebuild.
257
- */
258
- async scanCollection(collectionId) {
259
- try {
260
- const res = await this.api.scanCollection(collectionId);
261
- await this.refreshTasks();
262
- this.togglePanel(true);
263
- if (res.alreadyRunning) this.platform.notifications?.info?.('A scan of this collection is already running');
264
- this.#followUp();
265
- return res;
266
- } catch (err) {
267
- this.platform.notifications?.error?.(`Couldn't scan “${collectionId}”: ${err.message}`);
268
- throw err;
241
+ this.platform.notifications.error(`Couldn't dismiss: ${err.message}`);
269
242
  }
270
243
  }
271
244
 
272
245
  /**
273
- * Ask the server whether the backing stores are actually usable from a browser.
246
+ * Two bounded re-reads after an action that may have resolved something.
274
247
  *
275
- * Unlike a scan or a reindex this finishes in one round trip, so it reports its own
276
- * result rather than handing back a task: the answer to "did I fix the bucket?" should
277
- * be available immediately, and the issue list is refreshed so a problem that is now
278
- * fixed visibly disappears rather than sitting there until the next poll.
248
+ * Public, because the action that started the work is the thing that knows it may have
249
+ * resolved something a small, real loss of encapsulation in exchange for the three
250
+ * network verbs no longer living inside a resource.
279
251
  */
280
- async checkStorage() {
281
- try {
282
- const res = await this.api.checkStorage();
283
- await this.refresh();
284
- this.togglePanel(true);
285
- const problems = (res.results || []).reduce((n, r) => n + (r.findings?.length || 0), 0);
286
- if (problems) {
287
- this.platform.notifications?.warn?.(`Found ${problems} storage problem${problems === 1 ? '' : 's'} — see Activity`);
288
- } else if (!res.checked) {
289
- this.platform.notifications?.info?.('There are no collections to check yet');
290
- } else if (!res.corsChecked) {
291
- // Honest about what was not checked. Reporting "all good" having skipped the check
292
- // that matters is how a diagnostic stops being believed.
293
- this.platform.notifications?.info?.('Stores are reachable. Browser access was not checked — this drive has no public URL configured.');
294
- } else {
295
- this.platform.notifications?.success?.('Stores are reachable and allow browser access');
296
- }
297
- return res;
298
- } catch (err) {
299
- this.platform.notifications?.error?.(`Couldn't check the stores: ${err.message}`);
300
- throw err;
252
+ followUp() {
253
+ for (const ms of [400, 1500]) {
254
+ const t = setTimeout(() => { this.refreshTasks(); this.refreshIssues(); }, ms);
255
+ t?.unref?.();
301
256
  }
302
257
  }
303
258
 
304
- togglePanel(open) {
305
- this.#set({ open: open ?? !this.state.open });
306
- if (this.state.open) this.refresh();
307
- }
308
-
309
259
  get running() {
310
- return this.state.tasks.filter((t) => t.status === 'running');
260
+ return this.#state.tasks.filter((t) => t.status === 'running');
311
261
  }
312
262
 
313
263
  /**
@@ -1,8 +1,15 @@
1
- // Assemble the ngin engine and the reactive services into one `app` context,
2
- // register commands and built-in openers, and expose everything the UI needs.
3
- // The engine holds a single `app` singleton provider so Actions can reach the
4
- // platform and services; the app object also carries the engine so Actions can
5
- // dispatch follow-up Actions (the choreographer pattern).
1
+ // Assemble the ngin engine: one provider per named resource, then commands, the built-in
2
+ // context keys, and the boot dispatches.
3
+ //
4
+ // There is no `app` provider. It existed, and it made the engine's state one opaque blob —
5
+ // every query and every action leased the whole world, and a lease that always covers
6
+ // everything tells you nothing about what a piece of work touches or how long it needs it.
7
+ // It survived longest as the thing command handlers closed over, and went when they stopped
8
+ // being closures (see bl/commands.js). `static deps = ['explorer']` means what it says now,
9
+ // and web/test/leases.test.js checks that it stays true.
10
+ //
11
+ // `app` the OBJECT is still returned, because the shell and the e2e harness hold the
12
+ // services directly — but nothing leases it.
6
13
 
7
14
  import { Engine, Provider } from '@3sln/ngin';
8
15
  import { cell } from '../runtime.js';
@@ -14,7 +21,7 @@ import { SocialService } from './social.js';
14
21
  import { OfflineService } from './offline.js';
15
22
  import { ActivityService } from './activity.js';
16
23
  import { registerCommands } from './commands.js';
17
- import { NavigateAction, LoadCollectionsAction, OpenInitialCollectionAction, OpenPluginPanelAction } from './actions.js';
24
+ import { NavigateAction } from './actions.js';
18
25
 
19
26
  export function createApp(platform) {
20
27
  // Named slices, not services — see bl/state.js. Each is still its own provider, so a
@@ -135,7 +142,6 @@ export function createApp(platform) {
135
142
  },
136
143
  () => {},
137
144
  ),
138
- mediaUrls: Provider.fromSingleton(platform.mediaUrls),
139
145
  plugins: Provider.fromSingleton(platform.plugins),
140
146
  },
141
147
  });
@@ -149,10 +155,13 @@ export function createApp(platform) {
149
155
  // outside the engine, and one of them has to carry the intent in. Everything past this
150
156
  // point is an action on the feed.
151
157
  platform.commands.dispatch = (action) => engine.dispatch(action);
152
- // The same seam for the two other places that originate outside the engine: a docked
153
- // plugin frame opening a file, and the plugin RPC asking for its panel.
158
+ // The same seam for everything that originates outside the engine: a docked plugin frame
159
+ // opening a file, and the plugin RPC asking for its panel. ONE seam — there was a second,
160
+ // `openPluginPanel`, which was `dispatch` with one action pre-applied, declared
161
+ // differently (a field in the platform literal versus one materialising from nowhere) and
162
+ // guarded differently (`dispatch?.()` optional, `openPluginPanel()` not), so a reader
163
+ // looking for how the plugin layer reaches the engine had to find both.
154
164
  platform.dispatch = (action) => engine.dispatch(action);
155
- platform.openPluginPanel = (pluginId) => engine.dispatch(new OpenPluginPanelAction(pluginId));
156
165
 
157
166
  registerCommands(app);
158
167
 
@@ -165,7 +174,10 @@ export function createApp(platform) {
165
174
  social.init();
166
175
  offline.init();
167
176
  activity.init();
168
- engine.dispatch(new LoadCollectionsAction());
177
+ // NOT `LoadCollectionsAction` as well. `OpenInitialCollectionAction`, dispatched at boot
178
+ // by workbench.js, fetches and writes the same two fields on both its normal and its
179
+ // share-link path — so every cold start made two identical GETs to /api/collections and
180
+ // installed two writers for one fact.
169
181
 
170
182
  return { engine, app };
171
183
  }
@@ -16,6 +16,7 @@
16
16
  // carrying-a-handle problem that menu items had before they carried `actions`.
17
17
 
18
18
  import { parseTagQuery, filterLabel } from './tagQuery.js';
19
+ import { iconForKind } from './fileType.js';
19
20
  import {
20
21
  ExecCommandAction, CloseSearchModalAction, OpenFileAction, SearchAction, FilterAction,
21
22
  SetLaunchQueryAction,
@@ -27,14 +28,6 @@ export function launcherMode(query) {
27
28
  return q.startsWith('!') ? 'command' : q.includes('#') ? 'filter' : 'search';
28
29
  }
29
30
 
30
- const iconFor = (node) => {
31
- const t = node?.contentType || '';
32
- if (t.startsWith('image/')) return 'file-image';
33
- if (t.startsWith('audio/')) return 'file-audio';
34
- if (t.startsWith('video/')) return 'file-video';
35
- return 'file-text';
36
- };
37
-
38
31
  /**
39
32
  * Everything you can do to a file, as descriptions.
40
33
  *
@@ -80,7 +73,7 @@ export function trashMenuOf(node) {
80
73
  */
81
74
  function fileItem(node, { modal, pinnedIds, keys }) {
82
75
  return {
83
- icon: iconFor(node),
76
+ icon: iconForKind(node),
84
77
  title: node.name,
85
78
  detail: node.contentType || '',
86
79
  node,
@@ -10,30 +10,43 @@
10
10
  // and renaming breaks them. It has to say so clearly, and say WHY, because "nothing
11
11
  // happened" is the worst possible response to a click.
12
12
 
13
+ import { Action } from '@3sln/ngin';
13
14
  import { parseTroveUri } from '@3sln/trove/core/links.js';
15
+ import { runAction } from '../dispatch.js';
14
16
  import { OpenFileAction } from './actions.js';
15
17
 
16
18
  /**
17
19
  * Open what a `trove:` URI points at.
18
- * @param {object} ui the workbench ui helper
19
- * @param {string} uri the link as written in the document
20
- * @param {{from?: object}} [opts] the item the link was followed FROM, for messages
20
+ *
21
+ * An ACTION, because it is a network stat, a choice between three failure messages, and an
22
+ * open. It was a plain function taking the UI bag — the only module in bl/ whose parameter
23
+ * was a UI-layer shape, reaching `ui.platform.api` and `ui.engine` to do all of that with
24
+ * no lease on anything and nothing of it on the feed. pluginHost.js states the rule for the
25
+ * same kind of edge: "Dispatched, not called: opening a file from a docked plugin frame is
26
+ * the same intent as opening one from the drive, and the engine should see both."
27
+ *
28
+ * The laziness the module header argues for is untouched — this still runs at click time,
29
+ * and where the work runs was never what that argument was about.
21
30
  */
22
- export async function openTroveLink(ui, uri, { from } = {}) {
23
- const ref = parseTroveUri(uri);
24
- const notify = ui.platform.notifications;
25
- if (!ref) {
26
- notify.warn(`"${uri}" isn’t a valid Trove link.`);
27
- return null;
28
- }
29
- try {
30
- const res = await ui.platform.api.stat(uri);
31
- if (!res?.node) throw new Error('not found');
32
- ui.engine.dispatch(new OpenFileAction(res.node));
33
- return res.node;
34
- } catch (err) {
35
- notify.warn(describeBrokenLink(ref, err, from));
36
- return null;
31
+ export class OpenTroveLinkAction extends Action {
32
+ static deps = ['api', 'engine', 'notifications'];
33
+
34
+ /** @param {string} uri the link as written; @param {object} [from] the item it was in */
35
+ constructor(uri, from = null) { super(); this.uri = uri; this.from = from; }
36
+
37
+ async execute({ api, engine, notifications }) {
38
+ const ref = parseTroveUri(this.uri);
39
+ if (!ref) {
40
+ notifications.warn(`"${this.uri}" isn’t a valid Trove link.`);
41
+ return;
42
+ }
43
+ try {
44
+ const res = await api.stat(this.uri);
45
+ if (!res?.node) throw new Error('not found');
46
+ await runAction(engine, new OpenFileAction(res.node));
47
+ } catch (err) {
48
+ notifications.warn(describeBrokenLink(ref, err, this.from));
49
+ }
37
50
  }
38
51
  }
39
52
 
@@ -24,19 +24,33 @@ const FILES_CACHE = 'trove-files-v1';
24
24
  const embed = new LocalHashEmbedding({ dimensions: 256 });
25
25
 
26
26
  export class OfflineService {
27
+ #state;
28
+
27
29
  constructor(platform) {
28
30
  this.platform = platform;
29
31
  this.api = platform.api;
30
32
  this.db = null;
31
- this.state = { online: navigator.onLine, pins: [], queued: 0, syncing: false };
32
- this.cell = cell(this.state);
33
+ this.#state = { online: navigator.onLine, pins: [], queued: 0, syncing: false };
34
+ this.cell = cell(this.#state);
35
+ }
36
+ /**
37
+ * The value, for whoever is about to decide something from it.
38
+ *
39
+ * The same door every slice offers. Actions read `.state` and queries read `.cell`, and
40
+ * the two are only equal by habit — bl/state.js says so, and says a resource has one
41
+ * value and one way to read it. `state` is also a public field on a service, which is one
42
+ * typo from `social.state.sidecar = null` bypassing the cell and notifying nothing.
43
+ */
44
+ get() {
45
+ return this.#state;
33
46
  }
47
+
34
48
  observe() {
35
49
  return this.cell;
36
50
  }
37
51
  #set(patch) {
38
- this.state = { ...this.state, ...patch };
39
- this.cell.setValue(this.state);
52
+ this.#state = { ...this.#state, ...patch };
53
+ this.cell.setValue(this.#state);
40
54
  }
41
55
 
42
56
  async init() {
@@ -48,10 +62,10 @@ export class OfflineService {
48
62
  // `navigator.onLine` only reflects that an interface is up — on a captive portal or
49
63
  // dead uplink it's `true` while the server is unreachable, which would leave us
50
64
  // falsely "online" with every request silently failing. Confirm real reachability.
51
- if (this.state.online && !(await this.api.reachable())) this.#set({ online: false });
65
+ if (this.#state.online && !(await this.api.reachable())) this.#set({ online: false });
52
66
  // Tell the plugin host our initial connectivity so it can probe plugins.
53
- this.platform.plugins?.setOnline?.(this.state.online);
54
- if (this.state.online) this.flushQueue();
67
+ this.platform.plugins?.setOnline?.(this.#state.online);
68
+ if (this.#state.online) this.flushQueue();
55
69
  }
56
70
 
57
71
  async #onOnline() {
@@ -110,7 +124,7 @@ export class OfflineService {
110
124
  }
111
125
 
112
126
  async unpin(id) {
113
- const pin = this.state.pins.find((p) => p.id === id);
127
+ const pin = this.#state.pins.find((p) => p.id === id);
114
128
  if (pin && 'caches' in window) {
115
129
  const cache = await caches.open(FILES_CACHE);
116
130
  // The same stable key `pin` stored under — never a freshly minted URL.
@@ -157,7 +171,7 @@ export class OfflineService {
157
171
  }
158
172
 
159
173
  async flushQueue() {
160
- if (!this.state.online || this.state.syncing) return;
174
+ if (!this.#state.online || this.#state.syncing) return;
161
175
  // Claim the sync BEFORE any await — two 'online' events (or init + an event) can
162
176
  // fire concurrently, and a check-then-act guard after an await let both pass and
163
177
  // replay the queue twice (double-posting). Setting syncing synchronously here makes
@@ -189,7 +203,7 @@ export class OfflineService {
189
203
  this.#set({ syncing: false });
190
204
  // Be honest: only claim success for what actually applied, and surface drops.
191
205
  if (dropped) this.platform.notifications.warn(`${dropped} offline change${dropped > 1 ? 's' : ''} couldn't be applied and ${dropped > 1 ? 'were' : 'was'} discarded.`);
192
- if (synced && this.state.queued === 0) this.platform.notifications.info(`${synced} offline change${synced > 1 ? 's' : ''} synced.`);
206
+ if (synced && this.#state.queued === 0) this.platform.notifications.info(`${synced} offline change${synced > 1 ? 's' : ''} synced.`);
193
207
  }
194
208
  }
195
209
  }
@@ -9,8 +9,16 @@
9
9
  import { parsePackage, fetchPackage, reviewSummary, displayName } from '../platform/pluginPackage.js';
10
10
  import { pluginId } from '@3sln/trove/core/plugins/identity.js';
11
11
  import { installPolicyFor } from './trust.js';
12
+ import { InstallReviewedPluginAction } from './actions.js';
12
13
 
13
- /** @typedef {{notifications: object, overlay: object, plugins: object, social: object, workbench: object}} InstallResources */
14
+ /**
15
+ * What an install touches. The parameter list below is the same set spelled out, so a lease
16
+ * that forgot one is a missing ARGUMENT at the top of the function rather than an
17
+ * `undefined` several lines in — which is how a missing `overlay` came to be reported to
18
+ * the user as "Couldn't read the plugin", about a file that read perfectly.
19
+ *
20
+ * @typedef {{notifications: object, overlay: object, plugins: object, social: object, workbench: object}} InstallResources
21
+ */
14
22
 
15
23
  /** @param {InstallResources} r */
16
24
  export async function beginInstallFromFile(r, file) {
@@ -32,16 +40,17 @@ export async function beginInstallFromUrl(r, url) {
32
40
  }
33
41
  }
34
42
 
35
- async function review(r, pkg) {
43
+ /** @param {InstallResources} r */
44
+ async function review({ notifications, overlay, plugins, social, workbench }, pkg) {
36
45
  const label = displayName(pkg.manifest);
37
- if (r.plugins.plugins.has(pluginId(pkg.manifest))) {
38
- r.notifications.warn(`“${label}” is already installed.`);
46
+ if (plugins.plugins.has(pluginId(pkg.manifest))) {
47
+ notifications.warn(`“${label}” is already installed.`);
39
48
  return;
40
49
  }
41
50
  // Trust assessment can hit the network (domain assetlinks); do it before review.
42
51
  let trust = { status: 'unverified' };
43
52
  try {
44
- trust = await r.plugins.assessTrust(pkg);
53
+ trust = await plugins.assessTrust(pkg);
45
54
  } catch { /* offline / unreachable */ }
46
55
 
47
56
  // A tampered package is refused here rather than presented with a warning. The review
@@ -50,31 +59,25 @@ async function review(r, pkg) {
50
59
  // are not what was signed, so there is nothing to weigh. See installPolicyFor.
51
60
  const policy = installPolicyFor(trust);
52
61
  if (!policy.allowed) {
53
- r.notifications.error(`“${label}” was not installed. ${policy.detail}`, { sticky: true });
62
+ notifications.error(`“${label}” was not installed. ${policy.detail}`, { sticky: true });
54
63
  return;
55
64
  }
56
65
 
57
- const summary = reviewSummary(pkg, trust);
58
- r.overlay.set({ dialog: {
66
+ // `installActions`, not an `onInstall` closure. Two docblocks state the invariant —
67
+ // "Nothing in a dialog spec is callable, which matters because the spec lives in
68
+ // workbench state" (actions.js) and "the dialog spec holds no functions" (overlays.js) —
69
+ // and this dialog was the sole violation. That closure cleared the overlay, moved the
70
+ // workbench, raised a notification and performed the network install, all after the
71
+ // dispatching action's lease had been released and none of it on the feed.
72
+ //
73
+ // The alternative was already here: pluginReview keeps its ticked grants in viewState and
74
+ // dispatches per toggle, so the action needs to carry only `pkg` and `trust` and can read
75
+ // the grants from the key the dialog already writes.
76
+ overlay.set({ dialog: {
59
77
  kind: 'plugin-review',
60
- summary,
78
+ summary: reviewSummary(pkg, trust),
61
79
  policy,
62
- isAdmin: !!r.social.state.admin,
63
- onInstall: async (grants) => {
64
- r.overlay.set({ dialog: null });
65
- // Account installs upload the package + run a handshake that can take a while —
66
- // switch to the plugins view (which shows the plugin's loading state) and hold a
67
- // sticky "Installing…" toast so the user isn't left staring at nothing.
68
- r.workbench.set({ activity: 'plugins', sidebarVisible: true });
69
- const pending = r.notifications.info(`Installing “${label}”…`, { sticky: true });
70
- try {
71
- await r.plugins.install(pkg, { grants, trust });
72
- r.notifications.dismiss(pending);
73
- r.notifications.success(`Installed “${label}”`);
74
- } catch (err) {
75
- r.notifications.dismiss(pending);
76
- r.notifications.error(`Install failed: ${err.message}`);
77
- }
78
- },
80
+ isAdmin: !!social.get().admin,
81
+ installActions: [new InstallReviewedPluginAction(pkg, trust, label)],
79
82
  } });
80
83
  }