@3sln/trove 0.0.2
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 +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
// PluginHost — the plugin lifecycle orchestrator. It owns the set of installed
|
|
2
|
+
// plugins and their runtime records, and composes four focused collaborators:
|
|
3
|
+
//
|
|
4
|
+
// FrameManager (pluginFrames.js) — sandboxed iframe spawn/handshake/destroy
|
|
5
|
+
// PluginRpcRouter (pluginRpc.js) — the trusted boundary: capability-gated RPC
|
|
6
|
+
// FrameDock (pluginDock.js) — where a frame is shown; the floating dock
|
|
7
|
+
// MediaController (pluginMedia.js) — navigator.mediaSession bridging
|
|
8
|
+
//
|
|
9
|
+
// What stays here is the lifecycle proper: install/restore/reconcile/uninstall, the
|
|
10
|
+
// availability heartbeat, and mounting a plugin into a panel or viewer.
|
|
11
|
+
//
|
|
12
|
+
// A plugin's code runs inside a sandboxed iframe on an OPAQUE origin: it can't touch
|
|
13
|
+
// the host DOM, read cookies/storage, or fetch its own package files. Everything it
|
|
14
|
+
// can do flows over one transferred MessagePort, gated by the capabilities the user
|
|
15
|
+
// granted at install time. Plugins are persisted (PluginRegistry, IndexedDB) so they
|
|
16
|
+
// survive reloads, and re-announce a live capability manifest so the workbench knows
|
|
17
|
+
// what works right now (incl. offline). See pluginPackage.js / pluginSigning.js for
|
|
18
|
+
// install-time validation and the domain-verified trust signal.
|
|
19
|
+
|
|
20
|
+
import { zipSync } from 'fflate';
|
|
21
|
+
import { PluginRegistry } from './pluginStore.js';
|
|
22
|
+
import { ClientSqlProvider } from './pluginClientDb.js';
|
|
23
|
+
import { assessTrust } from './pluginSigning.js';
|
|
24
|
+
import { ADMIN_ONLY_CAPS, capabilityList, networkEndpoints, grantedStorageScopes, parsePackage, displayName, canExecuteCommand } from './pluginPackage.js';
|
|
25
|
+
import { declaredContributions, parseKeymap, serverIndexers } from '@3sln/trove/core/plugins/contributions.js';
|
|
26
|
+
import { pluginId, contribUri } from '@3sln/trove/core/plugins/identity.js';
|
|
27
|
+
import { endpointSummary } from './pluginNet.js';
|
|
28
|
+
import { MediaController } from './pluginMedia.js';
|
|
29
|
+
import { FrameDock } from './pluginDock.js';
|
|
30
|
+
import { FrameManager } from './pluginFrames.js';
|
|
31
|
+
import { PluginRpcRouter } from './pluginRpc.js';
|
|
32
|
+
// The canonical capability list lives in core (the server's authority); import it so
|
|
33
|
+
// client and server can't drift when a capability is added.
|
|
34
|
+
import { ALL_CAPABILITIES } from '@3sln/trove/core/plugins/package.js';
|
|
35
|
+
|
|
36
|
+
export class PluginHost {
|
|
37
|
+
constructor(platform, { heartbeatMs = 20000 } = {}) {
|
|
38
|
+
this.platform = platform;
|
|
39
|
+
this.plugins = new Map(); // id -> runtime record
|
|
40
|
+
this.registry = new PluginRegistry();
|
|
41
|
+
// On-device per-scope SQLite (wasm). A failed background persist is data a plugin
|
|
42
|
+
// already believes it saved, so it surfaces rather than disappearing into a log.
|
|
43
|
+
this.clientDb = new ClientSqlProvider({
|
|
44
|
+
onError: (err, key) => this.platform.notifications.error(`A plugin's on-device data couldn't be saved (${key}): ${err.message}`),
|
|
45
|
+
});
|
|
46
|
+
this.online = typeof navigator !== 'undefined' ? navigator.onLine : true;
|
|
47
|
+
this.heartbeatMs = heartbeatMs;
|
|
48
|
+
this._heartbeat = null;
|
|
49
|
+
this._probing = false;
|
|
50
|
+
this.cell = platform.reactive.cell([]);
|
|
51
|
+
|
|
52
|
+
// Collaborators. The dock destroys frames through the frame manager (assigned
|
|
53
|
+
// just below — the callback is lazy), and both are consulted on frame teardown.
|
|
54
|
+
this.media = new MediaController();
|
|
55
|
+
this.dock = new FrameDock({
|
|
56
|
+
destroyFrame: (frame) => this.frames.destroy(frame),
|
|
57
|
+
openFile: (node, openerId) => this.platform.workbench.openFile(node, openerId),
|
|
58
|
+
onChange: () => this.#emit(),
|
|
59
|
+
});
|
|
60
|
+
this.frames = new FrameManager({ media: this.media, dock: this.dock });
|
|
61
|
+
this.rpc = new PluginRpcRouter({
|
|
62
|
+
platform, clientDb: this.clientDb, media: this.media, dock: this.dock,
|
|
63
|
+
onChange: () => this.#emit(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
observe() {
|
|
68
|
+
return this.cell;
|
|
69
|
+
}
|
|
70
|
+
#emit() {
|
|
71
|
+
this.cell.setValue(this.list());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Spawn one sandboxed frame for `record`, wired to this host's RPC router.
|
|
76
|
+
* `entry` boots a different module of the plugin's tree (that's what an opener is).
|
|
77
|
+
*/
|
|
78
|
+
#spawnFrame(record, role, entry) {
|
|
79
|
+
return this.frames.spawn(record, role, {
|
|
80
|
+
onCall: (rec, m, p, f) => this.rpc.hostCall(rec, m, p, f),
|
|
81
|
+
onEvent: (rec, m, p, f) => this.rpc.hostEvent(rec, m, p, f),
|
|
82
|
+
online: this.online,
|
|
83
|
+
entry,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Register everything the MANIFEST declares, each at its own contribution URI. The
|
|
89
|
+
* manifest is authoritative — this runs before the plugin boots, so: the user's
|
|
90
|
+
* install-time review matches exactly what gets registered, openers/indexers exist
|
|
91
|
+
* without activating the plugin, and they survive a plugin whose primary frame is
|
|
92
|
+
* broken or unresponsive. Nothing a plugin does at runtime can add a contribution;
|
|
93
|
+
* it can only drive the ones declared here (push status content, set a register).
|
|
94
|
+
*/
|
|
95
|
+
#registerContributions(runtime) {
|
|
96
|
+
const pid = runtime.id;
|
|
97
|
+
const reg = this.platform.contributions;
|
|
98
|
+
const keep = (dispose) => runtime.disposers.push(dispose);
|
|
99
|
+
const label = displayName(runtime.manifest);
|
|
100
|
+
|
|
101
|
+
for (const c of declaredContributions(runtime.manifest)) {
|
|
102
|
+
const base = { ...c, pluginId: pid };
|
|
103
|
+
switch (c.type) {
|
|
104
|
+
// A viewer: `entry` names the module that renders it, in its own frame.
|
|
105
|
+
// Skipped entirely if the user declined "provides viewers" at install.
|
|
106
|
+
case 'opener':
|
|
107
|
+
if (runtime.grants.includes('opener')) keep(reg.register(c.uri, base));
|
|
108
|
+
break;
|
|
109
|
+
|
|
110
|
+
// Declared, implemented by the plugin's primary frame. The handler proxies
|
|
111
|
+
// over RPC using the contribution's short name — inside its own frame a
|
|
112
|
+
// plugin addresses its commands by name, not by URI.
|
|
113
|
+
case 'command':
|
|
114
|
+
keep(this.platform.commands.register({
|
|
115
|
+
id: c.uri, title: c.title || `${label}: ${c.name}`,
|
|
116
|
+
category: c.category || label, icon: c.icon,
|
|
117
|
+
when: c.when, offline: c.offline, palette: c.palette, pluginId: pid,
|
|
118
|
+
handler: (...args) => runtime.channel?.call('command:execute', { id: c.name, args }),
|
|
119
|
+
}));
|
|
120
|
+
break;
|
|
121
|
+
|
|
122
|
+
// A slot in the status bar, empty and hidden until the plugin pushes content.
|
|
123
|
+
// A CLICKABLE one is the plugin asking the host to run something — the same ask
|
|
124
|
+
// a keyboard shortcut makes — so it goes through the same per-command grant.
|
|
125
|
+
// Without this, a manifest holding only the `ui` capability could put an
|
|
126
|
+
// inviting button in the status bar wired to `explorer.delete` or
|
|
127
|
+
// `plugins.installFromUrl`, and the install review never mentioned the field.
|
|
128
|
+
case 'statusItem': {
|
|
129
|
+
const command = c.command
|
|
130
|
+
? this.#resolveCommand(runtime, c.command, label, `status item “${c.name}”`)
|
|
131
|
+
: null;
|
|
132
|
+
keep(reg.register(c.uri, { ...base, command, html: '', visible: false }));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A context value slot. Seed the context with its declared default so
|
|
137
|
+
// when-clauses referencing it evaluate sensibly before the plugin runs.
|
|
138
|
+
case 'register':
|
|
139
|
+
keep(reg.register(c.uri, base));
|
|
140
|
+
this.platform.context.set(c.uri, c.default);
|
|
141
|
+
keep(() => this.platform.context.remove(c.uri));
|
|
142
|
+
break;
|
|
143
|
+
|
|
144
|
+
// A keymap JSON file inside the package. Read + validated here, at register
|
|
145
|
+
// time, so a malformed keymap is a visible install-time problem rather than a
|
|
146
|
+
// shortcut that silently never fires.
|
|
147
|
+
case 'keymap': {
|
|
148
|
+
const bytes = runtime.files.get(c.path);
|
|
149
|
+
if (!bytes) {
|
|
150
|
+
this.platform.notifications.warn(`Plugin "${label}": keymap file "${c.path}" is not in the package.`);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const bindings = this.#resolveBindings(runtime, parseKeymap(new TextDecoder().decode(bytes)), label);
|
|
155
|
+
keep(reg.register(c.uri, { ...base, bindings }));
|
|
156
|
+
} catch (err) {
|
|
157
|
+
this.platform.notifications.warn(`Plugin "${label}": ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Indexers are deliberately NOT registered client-side. They run on the server
|
|
163
|
+
// (PluginIndexers registers them from the install record), because indexing
|
|
164
|
+
// must happen once per upload for the drive — not in whichever tab is open.
|
|
165
|
+
case 'indexer':
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve a keymap's bindings into real addresses, and refuse the ones the plugin
|
|
173
|
+
* isn't allowed to trigger. A binding naming one of the plugin's own commands is
|
|
174
|
+
* rewritten to that contribution's URI (inside its package a plugin knows only short
|
|
175
|
+
* names); anything else is a foreign address and must be in the manifest's `commands`
|
|
176
|
+
* allowlist — a shortcut is still the plugin asking the host to run something, so it
|
|
177
|
+
* can't be a way around the per-command grant the user approved.
|
|
178
|
+
*/
|
|
179
|
+
#resolveBindings(runtime, bindings, label) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const b of bindings) {
|
|
182
|
+
const command = this.#resolveCommand(runtime, b.command, label, `shortcut ${b.key}`);
|
|
183
|
+
if (command) out.push({ ...b, command });
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* One command address the plugin wants to be able to trigger, resolved and checked.
|
|
190
|
+
*
|
|
191
|
+
* A short name is one of the plugin's own commands and becomes its contribution URI.
|
|
192
|
+
* Anything else is a FOREIGN address and must appear in the manifest's `commands`
|
|
193
|
+
* allowlist. Returns null (with a warning naming `what`) when it isn't allowed, so
|
|
194
|
+
* every route by which a plugin can ask the host to run something — keymaps, status
|
|
195
|
+
* items — passes through the same grant the user actually approved.
|
|
196
|
+
*/
|
|
197
|
+
#resolveCommand(runtime, wanted, label, what) {
|
|
198
|
+
const uri = contribUri(runtime.manifest, wanted);
|
|
199
|
+
const own = declaredContributions(runtime.manifest).some((c) => c.uri === uri && c.type === 'command');
|
|
200
|
+
if (own) return uri;
|
|
201
|
+
if (!canExecuteCommand(runtime.manifest, wanted)) {
|
|
202
|
+
this.platform.notifications.warn(`Plugin "${label}": ${what} runs "${wanted}", which it isn't allowed to run — ignored.`);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
return wanted;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
list() {
|
|
209
|
+
return [...this.plugins.values()].map((p) => ({
|
|
210
|
+
id: p.id, name: displayName(p.manifest), version: p.manifest.version, status: p.status,
|
|
211
|
+
capabilities: p.grants, error: p.error || null, hasUi: p.hasUi, badge: p.badge || null,
|
|
212
|
+
trust: p.trust || null, settingsSchema: p.manifest.settings || [],
|
|
213
|
+
endpoints: endpointSummary(networkEndpoints(p.manifest)),
|
|
214
|
+
responsive: !!p.responsive, manifest: p.live || null, features: this.#featureList(p),
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* What this plugin contributes — read from the MANIFEST, so the list is known
|
|
220
|
+
* without the plugin running (and stays accurate if it stops responding). `live`
|
|
221
|
+
* is only used for liveness/availability, not for what exists.
|
|
222
|
+
*/
|
|
223
|
+
#featureList(record) {
|
|
224
|
+
let declared;
|
|
225
|
+
try { declared = declaredContributions(record.manifest); } catch { return []; }
|
|
226
|
+
return declared.map((c) => ({
|
|
227
|
+
kind: c.type, id: c.uri, name: c.name, title: c.title || c.name,
|
|
228
|
+
offline: !!c.offline, available: this.#availableSpec(record, c),
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
#availableSpec(record, spec) {
|
|
232
|
+
if (record.status !== 'active' || !record.responsive) return false;
|
|
233
|
+
return this.online || !!spec.offline;
|
|
234
|
+
}
|
|
235
|
+
isAvailable(contrib) {
|
|
236
|
+
if (!contrib?.pluginId) return true;
|
|
237
|
+
const record = this.plugins.get(contrib.pluginId);
|
|
238
|
+
if (!record) return false;
|
|
239
|
+
return this.#availableSpec(record, contrib);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- install / restore -----------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/** Whether the current user may grant a capability (some need admin). */
|
|
245
|
+
canGrant(cap, isAdmin) {
|
|
246
|
+
return !ADMIN_ONLY_CAPS.has(cap) || !!isAdmin;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Install a parsed package. `grants` is the subset of requested capabilities
|
|
251
|
+
* the user approved (the review UI filters admin-only caps for non-admins).
|
|
252
|
+
*/
|
|
253
|
+
async install(pkg, { grants, trust } = {}) {
|
|
254
|
+
const id = pluginId(pkg.manifest);
|
|
255
|
+
const requested = capabilityList(pkg.manifest);
|
|
256
|
+
const granted = (grants || requested).filter((c) => ALL_CAPABILITIES.includes(c) && requested.includes(c));
|
|
257
|
+
// Account-scoped plugins (server storage or a server indexer) upload their full
|
|
258
|
+
// package to the server: it re-validates, gates admin-only packages, and becomes
|
|
259
|
+
// the canonical copy so the plugin syncs to the user's other devices and its
|
|
260
|
+
// capabilities are enforced server-side. Throws if the server rejects (e.g. admin
|
|
261
|
+
// required) — surfaced to the user by the install UI. Device-scoped plugins stay local.
|
|
262
|
+
const scope = accountScoped(pkg.manifest, granted) ? 'account' : 'device';
|
|
263
|
+
if (scope === 'account') {
|
|
264
|
+
if (!pkg.raw) throw new Error('Package bytes unavailable for a server install');
|
|
265
|
+
await this.platform.api.installPlugin(pkg.raw, granted);
|
|
266
|
+
}
|
|
267
|
+
const storage = granted.includes('storage')
|
|
268
|
+
? grantedStorageScopes(pkg.manifest, trust)
|
|
269
|
+
: { plugin: false, domain: false };
|
|
270
|
+
const record = {
|
|
271
|
+
id, manifest: pkg.manifest,
|
|
272
|
+
files: Object.fromEntries([...pkg.files.entries()]),
|
|
273
|
+
grants: granted, storage, trust: trust || null, scope,
|
|
274
|
+
settings: {}, secrets: {}, installedAt: Date.now(),
|
|
275
|
+
};
|
|
276
|
+
await this.registry.save(record);
|
|
277
|
+
this.#registerSettings(record);
|
|
278
|
+
await this.#run(record);
|
|
279
|
+
return this.plugins.get(id);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Load all persisted plugins (call once at startup), then sync account plugins. */
|
|
283
|
+
async restore() {
|
|
284
|
+
let records = [];
|
|
285
|
+
try {
|
|
286
|
+
records = await this.registry.list();
|
|
287
|
+
} catch { /* no IndexedDB */ }
|
|
288
|
+
for (const rec of records) {
|
|
289
|
+
// A single corrupt persisted record (missing manifest, etc.) must not abort the
|
|
290
|
+
// restore of every other plugin — guard per record.
|
|
291
|
+
try {
|
|
292
|
+
this.#registerSettings(rec);
|
|
293
|
+
this.#run(rec).catch((e) => console.error('restore plugin failed', rec.id, e));
|
|
294
|
+
} catch (e) {
|
|
295
|
+
console.error('skipping corrupt plugin record', rec?.id, e);
|
|
296
|
+
this.platform.notifications.warn(`Couldn't restore plugin "${rec?.manifest ? displayName(rec.manifest) : rec?.id || 'unknown'}" — its saved data looks corrupt.`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Reconcile with the server's account plugins (best-effort, offline-tolerant).
|
|
300
|
+
this.#reconcile(records).catch(() => {});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Two-way sync of account plugins with the server: push any local account plugin the
|
|
304
|
+
// server is missing (migration for pre-existing local installs), and pull any server
|
|
305
|
+
// account plugin not yet on this device (cross-device sync).
|
|
306
|
+
async #reconcile(localRecords) {
|
|
307
|
+
let serverList;
|
|
308
|
+
try { serverList = (await this.platform.api.installedPlugins()).plugins || []; } catch { return; }
|
|
309
|
+
const serverIds = new Set(serverList.map((p) => p.pluginId));
|
|
310
|
+
const localIds = new Set(localRecords.map((r) => r.id));
|
|
311
|
+
let pushFailures = 0;
|
|
312
|
+
let pulled = 0;
|
|
313
|
+
let pullFailures = 0;
|
|
314
|
+
|
|
315
|
+
// Push: local account plugins the server doesn't have → re-upload (re-zipped).
|
|
316
|
+
for (const rec of localRecords) {
|
|
317
|
+
if (rec.scope !== 'account' || serverIds.has(rec.id)) continue;
|
|
318
|
+
try {
|
|
319
|
+
await this.platform.api.installPlugin(zipSync(toU8Files(rec.files)), rec.grants || []);
|
|
320
|
+
} catch (e) { console.error('re-upload plugin failed', rec.id, e); pushFailures++; }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Pull: server account plugins not on this device → download + enable.
|
|
324
|
+
for (const rec of serverList) {
|
|
325
|
+
if (localIds.has(rec.pluginId)) continue;
|
|
326
|
+
try {
|
|
327
|
+
const pkg = parsePackage(await this.platform.api.pluginPackage(rec.pluginId));
|
|
328
|
+
const grants = rec.grants || [];
|
|
329
|
+
const record = {
|
|
330
|
+
id: rec.pluginId, manifest: pkg.manifest,
|
|
331
|
+
files: Object.fromEntries([...pkg.files.entries()]),
|
|
332
|
+
grants,
|
|
333
|
+
// The SHARED domain scope comes from the server's install record, not from
|
|
334
|
+
// the package's own manifest. The install path gates it on verified domain
|
|
335
|
+
// ownership (`grantedStorageScopes` requires trust.status === 'verified') and
|
|
336
|
+
// the review UI shows it as blocked; this sync path set `trust: null` and read
|
|
337
|
+
// the self-declared manifest — so a package refused the shared store on the
|
|
338
|
+
// device it was installed on got it on the next device that synced.
|
|
339
|
+
storage: grants.includes('storage')
|
|
340
|
+
? { plugin: true, domain: !!rec.sharedStorage }
|
|
341
|
+
: { plugin: false, domain: false },
|
|
342
|
+
trust: null, scope: 'account',
|
|
343
|
+
settings: {}, secrets: {}, installedAt: rec.createdAt || Date.now(),
|
|
344
|
+
};
|
|
345
|
+
await this.registry.save(record);
|
|
346
|
+
this.#registerSettings(record);
|
|
347
|
+
await this.#run(record);
|
|
348
|
+
pulled++;
|
|
349
|
+
} catch (e) { console.error('sync plugin failed', rec.pluginId, e); pullFailures++; }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (pulled) this.platform.notifications.info(`Synced ${pulled} account plugin${pulled > 1 ? 's' : ''} from the server.`);
|
|
353
|
+
if (pushFailures || pullFailures) {
|
|
354
|
+
const parts = [];
|
|
355
|
+
if (pullFailures) parts.push(`${pullFailures} couldn't be downloaded`);
|
|
356
|
+
if (pushFailures) parts.push(`${pushFailures} couldn't be uploaded`);
|
|
357
|
+
this.platform.notifications.warn(`Some account plugins didn't sync: ${parts.join(', ')}.`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#registerSettings(record) {
|
|
362
|
+
const schema = (record.manifest.settings || []).filter((s) => !s.secret);
|
|
363
|
+
if (schema.length) {
|
|
364
|
+
record._settingsDispose = this.platform.settings.scopedFor(record.id).register(
|
|
365
|
+
schema.map((s) => ({ ...s, category: displayName(record.manifest) })),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// --- run (create the plugin's primary sandboxed iframe) --------------------
|
|
371
|
+
|
|
372
|
+
async #run(record) {
|
|
373
|
+
if (this.plugins.get(record.id)?.status === 'active') return;
|
|
374
|
+
const runtime = {
|
|
375
|
+
...record, iframe: null, status: 'loading', error: null, disposers: [], channel: null,
|
|
376
|
+
hasUi: false, responsive: false, frame: null, frames: new Set(),
|
|
377
|
+
// Storage scopes (defaulted for records saved before this field existed).
|
|
378
|
+
storage: record.storage || (record.grants?.includes('storage') ? grantedStorageScopes(record.manifest, record.trust) : { plugin: false, domain: false }),
|
|
379
|
+
files: mapFromFiles(record.files),
|
|
380
|
+
};
|
|
381
|
+
this.plugins.set(record.id, runtime);
|
|
382
|
+
// Register declared contributions BEFORE booting — they don't depend on the
|
|
383
|
+
// plugin running, and this keeps them alive if its frame never comes up.
|
|
384
|
+
try { this.#registerContributions(runtime); } catch (e) { console.error('registering contributions failed', record.id, e); }
|
|
385
|
+
this.#emit();
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
// The primary (background) frame: registers commands/indexers, does one-time
|
|
389
|
+
// setup. Its channel is the record's channel used for probes and commands.
|
|
390
|
+
const frame = await this.#spawnFrame(runtime, 'primary');
|
|
391
|
+
// Uninstalled while the handshake was in flight? The record is no longer in the
|
|
392
|
+
// map — tear this frame down instead of leaving a live, unreachable iframe.
|
|
393
|
+
if (this.plugins.get(record.id) !== runtime) { this.frames.destroy(frame); return runtime; }
|
|
394
|
+
runtime.frame = frame;
|
|
395
|
+
runtime.iframe = frame.iframe;
|
|
396
|
+
runtime.channel = frame.channel;
|
|
397
|
+
runtime.status = 'active';
|
|
398
|
+
runtime.responsive = true;
|
|
399
|
+
this.#probe(runtime).then(() => this.#emit());
|
|
400
|
+
this.#startHeartbeat();
|
|
401
|
+
} catch (err) {
|
|
402
|
+
runtime.status = 'error';
|
|
403
|
+
runtime.error = err?.message || String(err);
|
|
404
|
+
this.platform.notifications.error(`Plugin "${displayName(record.manifest)}" failed to load: ${runtime.error}`);
|
|
405
|
+
}
|
|
406
|
+
this.#emit();
|
|
407
|
+
return runtime;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// --- settings & secrets (host-side, for the UI) ----------------------------
|
|
411
|
+
|
|
412
|
+
async setSecret(pluginId, key, value) {
|
|
413
|
+
const rec = await this.registry.get(pluginId);
|
|
414
|
+
if (!rec) return;
|
|
415
|
+
rec.secrets = { ...rec.secrets, [key]: value };
|
|
416
|
+
await this.registry.save(rec);
|
|
417
|
+
const runtime = this.plugins.get(pluginId);
|
|
418
|
+
if (runtime) runtime.secrets = rec.secrets;
|
|
419
|
+
runtime?.channel?.emit('settings:changed', { key, value: '••••' });
|
|
420
|
+
}
|
|
421
|
+
async getSecret(pluginId, key) {
|
|
422
|
+
const rec = await this.registry.get(pluginId);
|
|
423
|
+
return rec?.secrets?.[key] ?? '';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// --- heartbeat / availability ----------------------------------------------
|
|
427
|
+
|
|
428
|
+
#startHeartbeat() {
|
|
429
|
+
if (this._heartbeat || !this.heartbeatMs) return;
|
|
430
|
+
this._heartbeat = setInterval(() => this.#heartbeat(), this.heartbeatMs);
|
|
431
|
+
if (this._heartbeat?.unref) this._heartbeat.unref();
|
|
432
|
+
}
|
|
433
|
+
#stopHeartbeat() {
|
|
434
|
+
if (this._heartbeat) clearInterval(this._heartbeat);
|
|
435
|
+
this._heartbeat = null;
|
|
436
|
+
}
|
|
437
|
+
async #heartbeat() {
|
|
438
|
+
if (this._probing) return;
|
|
439
|
+
this._probing = true;
|
|
440
|
+
try {
|
|
441
|
+
let changed = false;
|
|
442
|
+
for (const r of this.plugins.values()) if (r.status === 'active') changed = (await this.#probe(r)) || changed;
|
|
443
|
+
if (changed) this.#emit();
|
|
444
|
+
} finally {
|
|
445
|
+
this._probing = false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
setHeartbeat(ms) {
|
|
449
|
+
this.heartbeatMs = ms;
|
|
450
|
+
this.#stopHeartbeat();
|
|
451
|
+
if (ms && [...this.plugins.values()].some((r) => r.status === 'active')) this.#startHeartbeat();
|
|
452
|
+
}
|
|
453
|
+
async #probe(record) {
|
|
454
|
+
if (record.status !== 'active' || !record.channel) return false;
|
|
455
|
+
const before = { responsive: record.responsive, sig: signature(record.live) };
|
|
456
|
+
try {
|
|
457
|
+
record.live = await record.channel.call('manifest', {}, { timeout: 4000 });
|
|
458
|
+
record.responsive = true;
|
|
459
|
+
} catch {
|
|
460
|
+
record.responsive = false;
|
|
461
|
+
}
|
|
462
|
+
return before.responsive !== record.responsive || before.sig !== signature(record.live);
|
|
463
|
+
}
|
|
464
|
+
async setOnline(online) {
|
|
465
|
+
if (this.online === online) return;
|
|
466
|
+
this.online = online;
|
|
467
|
+
await Promise.all([...this.plugins.values()].filter((r) => r.status === 'active').map(async (r) => {
|
|
468
|
+
try { r.channel?.emit('connectivity', { online }); } catch { /* ignore */ }
|
|
469
|
+
for (const frame of r.frames || []) { try { frame.channel?.emit('connectivity', { online }); } catch { /* ignore */ } }
|
|
470
|
+
await this.#probe(r);
|
|
471
|
+
}));
|
|
472
|
+
this.#emit();
|
|
473
|
+
}
|
|
474
|
+
async refresh(pluginId) {
|
|
475
|
+
const record = this.plugins.get(pluginId);
|
|
476
|
+
if (record) { await this.#probe(record); this.#emit(); }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// --- mounting (panel / viewer) ---------------------------------------------
|
|
480
|
+
//
|
|
481
|
+
// Where a frame is *shown* is FrameDock's job (position:fixed overlay tracking a
|
|
482
|
+
// target element — we never re-parent an iframe, which would reload it). These
|
|
483
|
+
// methods own the mount lifecycle: which frame, opened with what, and what happens
|
|
484
|
+
// when it detaches.
|
|
485
|
+
|
|
486
|
+
mountPanel(pluginId, container, { width = 380, height = 480 } = {}) {
|
|
487
|
+
const record = this.plugins.get(pluginId);
|
|
488
|
+
if (!record?.frame) return null;
|
|
489
|
+
container.style.width = `${width}px`;
|
|
490
|
+
container.style.height = `${height}px`;
|
|
491
|
+
this.dock.place(record.frame, container, 50);
|
|
492
|
+
record.hasUi = true;
|
|
493
|
+
return () => this.dock.hide(record.frame);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Mount a plugin opener as the viewer for `node`. Each viewer runs in its OWN
|
|
498
|
+
* sandboxed iframe (spawned with the plugin's capabilities), so viewers are
|
|
499
|
+
* isolated from the background instance and from each other. Returns a detach fn:
|
|
500
|
+
* if the viewer registered `dock`, its frame is kept alive as a floating dock,
|
|
501
|
+
* otherwise it is destroyed.
|
|
502
|
+
*/
|
|
503
|
+
mountViewer(pluginId, container, node, openerId, hooks = {}) {
|
|
504
|
+
const record = this.plugins.get(pluginId);
|
|
505
|
+
if (!record) { hooks.onError?.('Plugin is not available'); return null; }
|
|
506
|
+
|
|
507
|
+
// Re-adopt the docked frame if it's the same file — preserves live playback
|
|
508
|
+
// (the docked <video>/<audio> keeps running) instead of spawning a fresh viewer.
|
|
509
|
+
const docked = this.dock.docked;
|
|
510
|
+
if (docked && docked.record === record && docked.node?.id === node.id && docked.openerId === openerId) {
|
|
511
|
+
this.dock.undock(docked);
|
|
512
|
+
this.dock.place(docked, container, 3);
|
|
513
|
+
hooks.onReady?.();
|
|
514
|
+
return () => this.#detachViewer(docked);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Boot the frame at the OPENER's entry module — not the plugin's main entry, so a
|
|
518
|
+
// viewer loads only the code it needs and never re-runs the plugin's background setup.
|
|
519
|
+
const opener = this.platform.contributions.get(openerId);
|
|
520
|
+
const mount = { cancelled: false, frame: null };
|
|
521
|
+
this.#spawnFrame(record, 'viewer', opener?.entry).then(async (frame) => {
|
|
522
|
+
if (mount.cancelled) { this.frames.destroy(frame); return; }
|
|
523
|
+
mount.frame = frame;
|
|
524
|
+
frame.node = node;
|
|
525
|
+
frame.openerId = openerId;
|
|
526
|
+
record.frames.add(frame);
|
|
527
|
+
this.dock.place(frame, container, 3);
|
|
528
|
+
try {
|
|
529
|
+
await frame.channel?.call('opener:open', { openerId, file: node, context: {} });
|
|
530
|
+
if (!mount.cancelled) hooks.onReady?.();
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (!mount.cancelled) hooks.onError?.(err?.message || 'Failed to open');
|
|
533
|
+
}
|
|
534
|
+
}).catch((e) => {
|
|
535
|
+
console.error('viewer frame failed to load', e);
|
|
536
|
+
if (!mount.cancelled) hooks.onError?.(e?.message || 'This viewer failed to load');
|
|
537
|
+
});
|
|
538
|
+
record.hasUi = true;
|
|
539
|
+
|
|
540
|
+
return () => {
|
|
541
|
+
mount.cancelled = true;
|
|
542
|
+
if (mount.frame) this.#detachViewer(mount.frame);
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// A viewer went off-screen: dock it (if it opted in and isn't dismissed) or destroy it.
|
|
547
|
+
#detachViewer(frame) {
|
|
548
|
+
if (frame.dock?.enabled && !frame.dock.dismissed) this.dock.dock(frame);
|
|
549
|
+
else this.frames.destroy(frame);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// --- uninstall --------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/** Uninstall: stop the plugin, forget it, and wipe everything it owns. */
|
|
555
|
+
async uninstall(pluginId, { wipeData = true } = {}) {
|
|
556
|
+
const record = this.plugins.get(pluginId);
|
|
557
|
+
const name = record?.manifest ? displayName(record.manifest) : pluginId;
|
|
558
|
+
const isAccount = record?.scope === 'account'
|
|
559
|
+
|| (await this.registry.get(pluginId).catch(() => null))?.scope === 'account';
|
|
560
|
+
|
|
561
|
+
// Account plugins: the server holds the canonical copy. Remove it there FIRST — if
|
|
562
|
+
// that fails we must NOT tear down locally, or the plugin silently resurrects on the
|
|
563
|
+
// next reload (server still lists it) with no sign anything went wrong.
|
|
564
|
+
if (wipeData && isAccount) {
|
|
565
|
+
try {
|
|
566
|
+
await this.platform.api.uninstallPluginServer(pluginId);
|
|
567
|
+
} catch (err) {
|
|
568
|
+
this.platform.notifications.error(`Couldn't uninstall "${name}" from the server: ${err.message}. It's still installed.`);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (record) {
|
|
574
|
+
try { record.channel?.emit('deactivate'); } catch { /* ignore */ }
|
|
575
|
+
for (const d of record.disposers) { try { d(); } catch { /* ignore */ } }
|
|
576
|
+
record._settingsDispose?.();
|
|
577
|
+
for (const frame of [...(record.frames || [])]) this.frames.destroy(frame); // viewer/dock frames
|
|
578
|
+
// The primary frame may have an open panel placement (observers + listeners).
|
|
579
|
+
if (record.frame) { this.dock.stopPlace(record.frame); this.media.releaseActions(record.frame); }
|
|
580
|
+
record.channel?.dispose();
|
|
581
|
+
record.iframe?.remove();
|
|
582
|
+
this.plugins.delete(pluginId);
|
|
583
|
+
}
|
|
584
|
+
// Best-effort cleanup of the plugin's private stores. A failure here doesn't block
|
|
585
|
+
// uninstall, but the user is told so leftover data isn't silently orphaned.
|
|
586
|
+
const wipeFailures = [];
|
|
587
|
+
if (wipeData) {
|
|
588
|
+
if (!isAccount) await this.platform.api.request('DELETE', `/api/plugins/${encodeURIComponent(pluginId)}/data`).catch(() => wipeFailures.push('server-side data'));
|
|
589
|
+
await this.clientDb.drop(`plg:${pluginId}`).catch(() => wipeFailures.push('on-device data'));
|
|
590
|
+
}
|
|
591
|
+
// The persisted record is what restore() reads: if this fails and we say nothing,
|
|
592
|
+
// the plugin silently reappears on the next reload looking like a bug in uninstall.
|
|
593
|
+
let resurrects = false;
|
|
594
|
+
try {
|
|
595
|
+
await this.registry.remove(pluginId);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
resurrects = true;
|
|
598
|
+
console.error('removing the persisted plugin record failed', pluginId, err);
|
|
599
|
+
}
|
|
600
|
+
if (![...this.plugins.values()].some((r) => r.status === 'active')) this.#stopHeartbeat();
|
|
601
|
+
this.#emit();
|
|
602
|
+
if (resurrects) {
|
|
603
|
+
this.platform.notifications.error(`Removed "${name}", but its saved record couldn't be deleted — it may come back when you reload.`);
|
|
604
|
+
} else if (wipeFailures.length) {
|
|
605
|
+
this.platform.notifications.warn(`Uninstalled "${name}", but couldn't clear its ${wipeFailures.join(' and ')}.`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// --- trust ------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
/** Fetch a domain's assetlinks doc through the server (avoids CORS). */
|
|
612
|
+
fetchAssetlinks = async (domain) => {
|
|
613
|
+
const res = await this.platform.api.request('GET', '/api/plugins/assetlinks', { query: { domain } });
|
|
614
|
+
return res?.assetlinks || null;
|
|
615
|
+
};
|
|
616
|
+
assessTrust(pkg) {
|
|
617
|
+
return assessTrust(pkg, this.fetchAssetlinks);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function mapFromFiles(files) {
|
|
622
|
+
const m = new Map();
|
|
623
|
+
for (const [k, v] of Object.entries(files)) m.set(k, v instanceof Uint8Array ? v : new Uint8Array(v));
|
|
624
|
+
return m;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Coerce a stored files object (path -> bytes, possibly ArrayBuffer after IndexedDB
|
|
628
|
+
// round-trip) into the { path: Uint8Array } shape fflate's zipSync expects.
|
|
629
|
+
function toU8Files(files) {
|
|
630
|
+
const out = {};
|
|
631
|
+
for (const [k, v] of Object.entries(files || {})) out[k] = v instanceof Uint8Array ? v : new Uint8Array(v);
|
|
632
|
+
return out;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// A plugin is account-scoped (must install to the server) if it has a server
|
|
636
|
+
// footprint: server storage, or an indexer (which the SERVER runs). Purely
|
|
637
|
+
// client-side plugins stay device-local.
|
|
638
|
+
function accountScoped(manifest, granted) {
|
|
639
|
+
if (granted.includes('storage')) return true;
|
|
640
|
+
return serverIndexers(manifest).length > 0;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// A cheap fingerprint of a plugin's live self-report, used to tell whether a heartbeat
|
|
644
|
+
// changed anything worth re-rendering for.
|
|
645
|
+
function signature(live) {
|
|
646
|
+
if (!live) return '';
|
|
647
|
+
return `${live.online ? 1 : 0}|${[...(live.handlers || [])].sort().join(',')}`;
|
|
648
|
+
}
|