@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.
- package/README.md +13 -1
- package/package.json +2 -1
- package/packages/core/src/collections/index.js +71 -10
- package/packages/core/src/encryption/keys.js +4 -3
- package/packages/core/src/encryption/policy.js +14 -7
- package/packages/core/src/encryption/rotation.js +46 -12
- package/packages/core/src/errors.js +8 -0
- package/packages/core/src/index.js +8 -2
- package/packages/core/src/indexers/contribution.js +18 -17
- package/packages/core/src/indexing.js +8 -4
- package/packages/core/src/issues.js +8 -0
- package/packages/core/src/links.js +21 -9
- package/packages/core/src/metadata/interface.js +22 -0
- package/packages/core/src/metadata/memory.js +15 -0
- package/packages/core/src/metadata/sqlite.js +23 -1
- package/packages/core/src/plugins/contributions.js +13 -0
- package/packages/core/src/rateLimit.js +253 -0
- package/packages/core/src/scan.js +44 -16
- package/packages/core/src/sidecar/document.js +18 -14
- package/packages/core/src/sidecar/index.js +9 -10
- package/packages/core/src/sidecar/manager.js +10 -15
- package/packages/core/src/uploads.js +40 -15
- package/packages/core/src/vfs.js +59 -34
- package/packages/plugin-sdk/src/browser.js +155 -0
- package/packages/plugin-sdk/src/protocol.js +13 -1
- package/packages/server/src/adapters/bun.js +1 -2
- package/packages/server/src/adapters/node.js +1 -2
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +17 -34
- package/packages/server/src/engine/providers/core.js +112 -14
- package/packages/server/src/index.js +145 -103
- package/packages/server/src/mcp/index.js +7 -12
- package/packages/server/src/mcp/tools.js +24 -29
- package/packages/server/src/router.js +31 -8
- package/packages/server/src/routes.js +74 -113
- package/packages/server/src/scope.js +78 -0
- package/packages/web/dist/assets/main-828yzsr7.js +511 -0
- package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +226 -104
- package/packages/web/src/bl/activity.js +32 -82
- package/packages/web/src/bl/index.js +23 -11
- package/packages/web/src/bl/launcher.js +2 -9
- package/packages/web/src/bl/links.js +31 -18
- package/packages/web/src/bl/offline.js +24 -10
- package/packages/web/src/bl/pluginInstall.js +29 -26
- package/packages/web/src/bl/queries.js +76 -68
- package/packages/web/src/bl/services.js +22 -14
- package/packages/web/src/bl/social.js +39 -25
- package/packages/web/src/bl/state.js +22 -4
- package/packages/web/src/bl/viewState.js +9 -0
- package/packages/web/src/bl/watchQuery.js +1 -8
- package/packages/web/src/dispatch.js +26 -0
- package/packages/web/src/platform/api.js +32 -0
- package/packages/web/src/platform/commands.js +14 -15
- package/packages/web/src/platform/context.js +3 -58
- package/packages/web/src/platform/contributions.js +1 -35
- package/packages/web/src/platform/fileChunks.js +281 -0
- package/packages/web/src/platform/index.js +8 -9
- package/packages/web/src/platform/mediaUrls.js +10 -0
- package/packages/web/src/platform/navigation.js +35 -21
- package/packages/web/src/platform/pickers.js +39 -19
- package/packages/web/src/platform/pluginHost.js +4 -1
- package/packages/web/src/platform/pluginRpc.js +47 -2
- package/packages/web/src/platform/spatialNav.js +1 -1
- package/packages/web/src/platform/viewport.js +14 -6
- package/packages/web/src/platform/voiceSearch.js +17 -9
- package/packages/web/src/platform/whenclause.js +9 -19
- package/packages/web/src/runtime.js +2 -37
- package/packages/web/src/ui/activate.js +18 -2
- package/packages/web/src/ui/components/activityPanel.js +1 -1
- package/packages/web/src/ui/components/adminView.js +4 -7
- package/packages/web/src/ui/components/editorArea.js +10 -1
- package/packages/web/src/ui/components/launcher.js +29 -13
- package/packages/web/src/ui/components/openers/index.js +17 -22
- package/packages/web/src/ui/components/openers/markdown.js +2 -2
- package/packages/web/src/ui/components/overlays.js +31 -5
- package/packages/web/src/ui/components/pluginReview.js +7 -4
- package/packages/web/src/ui/components/pluginsView.js +1 -1
- package/packages/web/src/ui/components/settingsView.js +30 -27
- package/packages/web/src/ui/components/statusBar.js +8 -6
- package/packages/web/src/ui/components/views/grid.js +2 -2
- package/packages/web/src/ui/components/views/parts.js +1 -1
- package/packages/web/src/ui/compositions/workbench.js +20 -6
- package/packages/web/src/ui/sanitize.js +0 -5
- package/packages/web/src/workbench.js +37 -19
- package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
- 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
|
|
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
|
|
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
|
|
63
|
-
this.cell.setValue(this
|
|
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
|
|
215
|
-
this.platform.notifications
|
|
227
|
+
this.followUp();
|
|
228
|
+
this.platform.notifications.info('Retrying — watch its progress in Activity');
|
|
216
229
|
} catch (err) {
|
|
217
|
-
this.platform.notifications
|
|
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
|
|
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
|
|
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
|
-
*
|
|
246
|
+
* Two bounded re-reads after an action that may have resolved something.
|
|
274
247
|
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
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
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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
|
|
153
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
32
|
-
this.cell = cell(this
|
|
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
|
|
39
|
-
this.cell.setValue(this
|
|
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
|
|
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
|
|
54
|
-
if (this
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
43
|
+
/** @param {InstallResources} r */
|
|
44
|
+
async function review({ notifications, overlay, plugins, social, workbench }, pkg) {
|
|
36
45
|
const label = displayName(pkg.manifest);
|
|
37
|
-
if (
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
62
|
+
notifications.error(`“${label}” was not installed. ${policy.detail}`, { sticky: true });
|
|
54
63
|
return;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
|
|
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: !!
|
|
63
|
-
|
|
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
|
}
|