@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,62 @@
|
|
|
1
|
+
// MediaController — bridges a plugin frame's playback to the OS via
|
|
2
|
+
// navigator.mediaSession, so a sandboxed audio/video viewer surfaces real transport
|
|
3
|
+
// controls (lock screen, media keys, notification shade).
|
|
4
|
+
//
|
|
5
|
+
// The host owns the single global mediaSession; the LAST frame to touch it becomes
|
|
6
|
+
// the owner. OS actions fire back over that frame's own RPC channel, so a handler is
|
|
7
|
+
// only ever wired to a live frame — and the handlers a frame registered are released
|
|
8
|
+
// when it goes away (otherwise the OS would call into a dead channel).
|
|
9
|
+
|
|
10
|
+
export class MediaController {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.owner = null; // the frame currently driving navigator.mediaSession
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#session() {
|
|
16
|
+
return typeof navigator !== 'undefined' ? navigator.mediaSession : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Apply one media operation for `frame`. kind: metadata|playbackState|position|action|clear */
|
|
20
|
+
apply(frame, kind, params = {}) {
|
|
21
|
+
const ms = this.#session();
|
|
22
|
+
if (!ms) return { ok: false };
|
|
23
|
+
if (frame && kind !== 'clear') { this.owner = frame; frame.mediaOwner = true; }
|
|
24
|
+
try {
|
|
25
|
+
if (kind === 'metadata') {
|
|
26
|
+
ms.metadata = typeof MediaMetadata !== 'undefined'
|
|
27
|
+
? new MediaMetadata({ title: params.title || '', artist: params.artist || '', album: params.album || '', artwork: params.artwork || [] })
|
|
28
|
+
: ms.metadata;
|
|
29
|
+
} else if (kind === 'playbackState') {
|
|
30
|
+
ms.playbackState = params.state || 'none';
|
|
31
|
+
} else if (kind === 'position' && ms.setPositionState) {
|
|
32
|
+
ms.setPositionState({ duration: params.duration || 0, position: params.position || 0, playbackRate: params.playbackRate || 1 });
|
|
33
|
+
} else if (kind === 'action') {
|
|
34
|
+
ms.setActionHandler?.(params.action, params.on ? () => frame?.channel?.emit('media:action', { action: params.action }) : null);
|
|
35
|
+
// Remember which actions this frame registered, so we can release them when
|
|
36
|
+
// the frame goes away (otherwise the OS handler points at a dead channel).
|
|
37
|
+
if (frame) { frame.mediaActions ||= new Set(); params.on ? frame.mediaActions.add(params.action) : frame.mediaActions.delete(params.action); }
|
|
38
|
+
} else if (kind === 'clear') {
|
|
39
|
+
if (frame && this.owner && this.owner !== frame) return { ok: true }; // don't clear someone else's session
|
|
40
|
+
this.releaseActions(frame);
|
|
41
|
+
ms.metadata = null;
|
|
42
|
+
ms.playbackState = 'none';
|
|
43
|
+
this.owner = null;
|
|
44
|
+
}
|
|
45
|
+
} catch { /* unsupported action/state */ }
|
|
46
|
+
return { ok: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Detach the OS media-transport handlers a frame registered (on teardown). */
|
|
50
|
+
releaseActions(frame) {
|
|
51
|
+
const ms = this.#session();
|
|
52
|
+
if (!ms || !frame?.mediaActions) return;
|
|
53
|
+
for (const action of frame.mediaActions) { try { ms.setActionHandler?.(action, null); } catch { /* ignore */ } }
|
|
54
|
+
frame.mediaActions.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Frame teardown: give up the session if this frame owned it, then drop its handlers. */
|
|
58
|
+
releaseFrame(frame) {
|
|
59
|
+
if (this.owner === frame) { this.owner = null; this.apply(frame, 'clear', {}); }
|
|
60
|
+
this.releaseActions(frame); // drop handlers even if this frame wasn't the owner
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Multi-file plugins — native ESM without a bundler. A package can put its code
|
|
2
|
+
// under `src/` and use ordinary relative imports (`import './util.js'`); assets
|
|
3
|
+
// live anywhere else and are reached as opaque resources. We can't just let the
|
|
4
|
+
// sandboxed frame import these: it's on an opaque origin and can't fetch its own
|
|
5
|
+
// files, and a blob: module's base URL is meaningless so `./util.js` wouldn't
|
|
6
|
+
// resolve. So the host loads every `src/*.js` module as a blob: URL inside the
|
|
7
|
+
// frame and wires them together with an import map — and, because an import map is
|
|
8
|
+
// keyed by name (not text order), circular imports and any load order just work.
|
|
9
|
+
//
|
|
10
|
+
// The one preprocessing step: relative specifiers are rewritten to a canonical
|
|
11
|
+
// `trove:/<path>` key (resolved against the importing module) so they match the
|
|
12
|
+
// import map instead of resolving against the opaque blob base. We use
|
|
13
|
+
// es-module-lexer to find the exact specifier spans, so strings/comments/regex are
|
|
14
|
+
// never touched. The SDK is exposed as the bare specifier `trove`, so plugins can
|
|
15
|
+
// write either `import { activate } from 'trove'` or use the `globalThis.trove`
|
|
16
|
+
// the injected SDK also sets.
|
|
17
|
+
|
|
18
|
+
import { init, parse } from 'es-module-lexer';
|
|
19
|
+
|
|
20
|
+
const JS_RE = /\.m?js$/i;
|
|
21
|
+
|
|
22
|
+
/** A file that participates in the module graph (code under `src/`). */
|
|
23
|
+
export function isSourceModule(path) {
|
|
24
|
+
return /^src\//i.test(path) && JS_RE.test(path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a package should run in ESM module mode (its entry is a src module). */
|
|
28
|
+
export function isModuleEntry(manifest) {
|
|
29
|
+
return isSourceModule(manifest.entry || '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function dirParts(p) {
|
|
33
|
+
const i = p.lastIndexOf('/');
|
|
34
|
+
return (i < 0 ? '' : p.slice(0, i)).split('/').filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve a relative specifier against the importer's package path. */
|
|
38
|
+
function resolvePath(importer, spec) {
|
|
39
|
+
const stack = dirParts(importer);
|
|
40
|
+
for (const part of spec.split('/')) {
|
|
41
|
+
if (part === '' || part === '.') continue;
|
|
42
|
+
else if (part === '..') stack.pop();
|
|
43
|
+
else stack.push(part);
|
|
44
|
+
}
|
|
45
|
+
return stack.join('/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the rewritten module set + entry for a package. Returns
|
|
50
|
+
* { modules: { path: code }, entry } where each module's relative imports point at
|
|
51
|
+
* canonical `trove:/<path>` keys. Only `src/*.js` files are included.
|
|
52
|
+
*/
|
|
53
|
+
export async function buildModuleGraph(pkg) {
|
|
54
|
+
await init;
|
|
55
|
+
const modules = {};
|
|
56
|
+
for (const [path, bytes] of pkg.files) {
|
|
57
|
+
if (isSourceModule(path)) modules[path] = new TextDecoder().decode(bytes);
|
|
58
|
+
}
|
|
59
|
+
const has = (p) => Object.prototype.hasOwnProperty.call(modules, p);
|
|
60
|
+
const resolveModule = (importer, spec) => {
|
|
61
|
+
const r = resolvePath(importer, spec);
|
|
62
|
+
if (has(r)) return r;
|
|
63
|
+
if (has(r + '.js')) return r + '.js';
|
|
64
|
+
if (has(r + '.mjs')) return r + '.mjs';
|
|
65
|
+
if (has(r + '/index.js')) return r + '/index.js';
|
|
66
|
+
return r; // unresolved → import fails with a clear "no such module" error
|
|
67
|
+
};
|
|
68
|
+
const rewritten = {};
|
|
69
|
+
for (const [path, code] of Object.entries(modules)) {
|
|
70
|
+
rewritten[path] = rewriteSpecifiers(code, path, resolveModule);
|
|
71
|
+
}
|
|
72
|
+
return { modules: rewritten, entry: pkg.manifest.entry };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Rewrite relative import specifiers in one module to canonical map keys. */
|
|
76
|
+
export function rewriteSpecifiers(code, path, resolveModule) {
|
|
77
|
+
const [imports] = parse(code);
|
|
78
|
+
let out = '';
|
|
79
|
+
let last = 0;
|
|
80
|
+
for (const imp of imports) {
|
|
81
|
+
if (imp.n == null) continue; // dynamic import with a non-literal argument
|
|
82
|
+
if (imp.n[0] !== '.') continue; // only relative specifiers are remapped
|
|
83
|
+
const key = 'trove:/' + resolveModule(path, imp.n);
|
|
84
|
+
// Static spans exclude the quotes; dynamic import() spans include them.
|
|
85
|
+
const replacement = imp.d === -1 ? key : JSON.stringify(key);
|
|
86
|
+
out += code.slice(last, imp.s) + replacement;
|
|
87
|
+
last = imp.e;
|
|
88
|
+
}
|
|
89
|
+
return out + code.slice(last);
|
|
90
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Declared network endpoints — the allowlist a plugin must publish before it can
|
|
2
|
+
// talk to the web. The sandboxed iframe can't reach the network at all (strict
|
|
3
|
+
// `connect-src 'none'` CSP + opaque origin); every request is brokered by the host
|
|
4
|
+
// over the MessagePort, and the host only makes it if the URL matches one of these
|
|
5
|
+
// declared endpoints. So the manifest's `network` array is the *complete* list of
|
|
6
|
+
// hosts a plugin can ever reach, shown to the user at install time and enforced at
|
|
7
|
+
// runtime.
|
|
8
|
+
//
|
|
9
|
+
// An endpoint is a URL prefix: scheme + host (optionally `*.`-wildcarded) + port +
|
|
10
|
+
// path prefix. A request matches when its scheme, host, port, and path-prefix all
|
|
11
|
+
// agree. Query strings and fragments are ignored in the pattern.
|
|
12
|
+
|
|
13
|
+
/** Parse one endpoint pattern; throws on anything that isn't an http(s) URL. */
|
|
14
|
+
export function parseEndpoint(pattern) {
|
|
15
|
+
// Strip a leading `*.` wildcard BEFORE parsing — `*` isn't a valid host code
|
|
16
|
+
// point, and URL parsers disagree on how to mangle it (Chromium vs ada/Node),
|
|
17
|
+
// so we never hand it to `new URL()`.
|
|
18
|
+
const wild = /^([a-z][a-z0-9+.-]*:\/\/)\*\.(.+)$/i.exec(pattern);
|
|
19
|
+
const wildcard = !!wild;
|
|
20
|
+
let u;
|
|
21
|
+
try {
|
|
22
|
+
u = new URL(wildcard ? wild[1] + wild[2] : pattern);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Invalid network endpoint "${pattern}" (must be an absolute http(s) URL)`);
|
|
25
|
+
}
|
|
26
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
27
|
+
throw new Error(`Network endpoint "${pattern}" must use http or https`);
|
|
28
|
+
}
|
|
29
|
+
const host = u.hostname.toLowerCase();
|
|
30
|
+
if (!host) throw new Error(`Network endpoint "${pattern}" has no host`);
|
|
31
|
+
return { scheme: u.protocol, host, wildcard, port: u.port, pathPrefix: u.pathname || '/', raw: pattern };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse a manifest's `network` list, silently dropping malformed entries. */
|
|
35
|
+
export function normalizeEndpoints(list) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const p of list || []) {
|
|
38
|
+
try { out.push(parseEndpoint(p)); } catch { /* skip — validated separately at parse time */ }
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Does a request URL fall under a single parsed endpoint? */
|
|
44
|
+
export function matchesEndpoint(ep, url) {
|
|
45
|
+
let u;
|
|
46
|
+
try { u = new URL(url); } catch { return false; }
|
|
47
|
+
if (u.protocol !== ep.scheme) return false;
|
|
48
|
+
const host = u.hostname.toLowerCase();
|
|
49
|
+
if (ep.wildcard) {
|
|
50
|
+
if (host !== ep.host && !host.endsWith('.' + ep.host)) return false;
|
|
51
|
+
} else if (host !== ep.host) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (u.port !== ep.port) return false; // '' === '' for default ports (browser normalizes)
|
|
55
|
+
return u.pathname.startsWith(ep.pathPrefix);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Is `url` allowed by the plugin's declared endpoint list? */
|
|
59
|
+
export function isAllowedUrl(endpoints, url) {
|
|
60
|
+
return normalizeEndpoints(endpoints).some((ep) => matchesEndpoint(ep, url));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A human-friendly summary of declared endpoints for the review UI. */
|
|
64
|
+
export function endpointSummary(endpoints) {
|
|
65
|
+
return normalizeEndpoints(endpoints).map((e) => ({
|
|
66
|
+
scheme: e.scheme.replace(':', ''),
|
|
67
|
+
host: (e.wildcard ? '*.' : '') + e.host + (e.port ? ':' + e.port : ''),
|
|
68
|
+
path: e.pathPrefix === '/' ? '' : e.pathPrefix,
|
|
69
|
+
raw: e.raw,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Plugin packages — a plugin is a zip: manifest.json + an entry script + assets.
|
|
2
|
+
// This parses/validates a package (from an uploaded file or a fetched URL) and
|
|
3
|
+
// produces the summary shown in the pre-install review. Nothing here runs plugin
|
|
4
|
+
// code — that only happens later, inside a sandboxed iframe.
|
|
5
|
+
|
|
6
|
+
import { unzipSync } from 'fflate';
|
|
7
|
+
import { parseEndpoint, endpointSummary } from './pluginNet.js';
|
|
8
|
+
import { assertIdentity, pluginId, ownsUri, parseContribUri } from '@3sln/trove/core/plugins/identity.js';
|
|
9
|
+
import { declaredContributions } from '@3sln/trove/core/plugins/contributions.js';
|
|
10
|
+
|
|
11
|
+
const CAP_DESCRIPTIONS = {
|
|
12
|
+
files: 'Read your files, folders, and search index (via the host).',
|
|
13
|
+
storage: 'Keep its own SQLite database(s) — on the server and/or on this device.',
|
|
14
|
+
ui: 'Show a popup panel and toasts.',
|
|
15
|
+
commands: 'Add commands to the palette, and run the specific host commands it lists.',
|
|
16
|
+
opener: 'Preview/open file types.',
|
|
17
|
+
// Distinct from a declared indexer (which the SERVER runs on every upload): this
|
|
18
|
+
// capability lets the plugin itself push searchable content for a file it's looking at.
|
|
19
|
+
indexer: 'Add searchable content to your files.',
|
|
20
|
+
network: 'Connect to the internet — only the endpoints it declares (shown below).',
|
|
21
|
+
media: 'Show playback controls on your lock screen and notifications while it plays media.',
|
|
22
|
+
dock: 'Keep its viewer in a small floating window when you navigate away.',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Capabilities that need an admin to grant (they touch shared data). None at the
|
|
26
|
+
// moment — the `storage.domain` scope is instead gated on domain verification.
|
|
27
|
+
export const ADMIN_ONLY_CAPS = new Set();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The storage scopes a manifest declares: `{ plugin, domain }`. Each is a separate
|
|
31
|
+
* lazily-created SQLite database (server + client). The `domain` scope — shared
|
|
32
|
+
* across a vendor's plugins — is only usable by a domain-verified package.
|
|
33
|
+
*/
|
|
34
|
+
export function storageScopes(manifest) {
|
|
35
|
+
const opt = capabilityOptions(manifest, 'storage');
|
|
36
|
+
if (!opt) return { plugin: false, domain: false };
|
|
37
|
+
// `storage: true` (or `{}`) → the private plugin scope, nothing shared.
|
|
38
|
+
if (opt === true || (typeof opt === 'object' && !('plugin' in opt) && !('domain' in opt))) {
|
|
39
|
+
return { plugin: true, domain: false };
|
|
40
|
+
}
|
|
41
|
+
return { plugin: !!opt.plugin, domain: !!opt.domain };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function describeCapability(cap) {
|
|
45
|
+
return CAP_DESCRIPTIONS[cap] || cap;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Capabilities are declared as an object: each key is a capability, each value is
|
|
49
|
+
// that capability's options. A capability that takes no options uses `true` (or an
|
|
50
|
+
// empty object). e.g. { ui: true, network: { endpoints: ["https://api.example.com/"] } }.
|
|
51
|
+
// A plain array of ids is also accepted (each treated as options-less) so simple
|
|
52
|
+
// or hand-written manifests stay easy.
|
|
53
|
+
export function capabilityEntries(manifest) {
|
|
54
|
+
const caps = manifest && manifest.capabilities;
|
|
55
|
+
const out = {};
|
|
56
|
+
if (Array.isArray(caps)) {
|
|
57
|
+
for (const id of caps) if (id) out[id] = {};
|
|
58
|
+
} else if (caps && typeof caps === 'object') {
|
|
59
|
+
for (const [id, val] of Object.entries(caps)) {
|
|
60
|
+
if (val === false || val == null) continue; // explicitly not requested
|
|
61
|
+
out[id] = val === true ? {} : val;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The declared capability ids. */
|
|
68
|
+
export function capabilityList(manifest) {
|
|
69
|
+
return Object.keys(capabilityEntries(manifest));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Options declared for one capability, or null if it isn't declared. */
|
|
73
|
+
export function capabilityOptions(manifest, id) {
|
|
74
|
+
const e = capabilityEntries(manifest);
|
|
75
|
+
return Object.prototype.hasOwnProperty.call(e, id) ? e[id] : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The network capability's declared endpoint prefixes (accepts a few shapes). */
|
|
79
|
+
export function networkEndpoints(manifest) {
|
|
80
|
+
const opt = capabilityOptions(manifest, 'network');
|
|
81
|
+
if (opt) {
|
|
82
|
+
if (Array.isArray(opt)) return opt; // network: ["https://…"]
|
|
83
|
+
if (Array.isArray(opt.endpoints)) return opt.endpoints;
|
|
84
|
+
if (Array.isArray(opt.prefixes)) return opt.prefixes;
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(manifest.network)) return manifest.network; // legacy top-level
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The human-facing name: `displayName` if given, else the package name. */
|
|
91
|
+
export function displayName(manifest) {
|
|
92
|
+
// A STRING, always — and this is a security boundary, not tidiness.
|
|
93
|
+
//
|
|
94
|
+
// dodo decides "is this argument a props map or a child?" by `x.constructor === Object`,
|
|
95
|
+
// and every prop on an HTML element is assigned as a DOM PROPERTY. `JSON.parse` gives
|
|
96
|
+
// objects whose constructor is Object, so a manifest with
|
|
97
|
+
// `"displayName": {"innerHTML": "<img src=x onerror=…>"}` — nothing validates it —
|
|
98
|
+
// reaches `span(plugin.name)` in the plugin panel and `h3(g.category)` in Settings as
|
|
99
|
+
// the FIRST argument, becomes the element's prop map, and executes in the host page.
|
|
100
|
+
// That is a complete escape from the opaque-origin, connect-src 'none' iframe.
|
|
101
|
+
const name = manifest?.displayName ?? manifest?.name;
|
|
102
|
+
return typeof name === 'string' && name.trim() ? name : 'Plugin';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The exact commands this plugin may ASK THE HOST TO RUN (ctx.commands.execute), each
|
|
107
|
+
* named by the URI it's contributed at — a built-in like `explorer.download`, or
|
|
108
|
+
* another plugin's `trove+contrib:acme.com/docs/export`. Like `network`, the
|
|
109
|
+
* capability carries its allowlist rather than being a blanket grant: "can run
|
|
110
|
+
* commands" is meaningless as a yes/no, because the interesting question is always
|
|
111
|
+
* *which* ones (`explorer.delete` is not `workbench.view.home`).
|
|
112
|
+
* Accepted shapes:
|
|
113
|
+
* commands: true → contribute-only; executes nothing external
|
|
114
|
+
* commands: ["explorer.download", …] → exactly these
|
|
115
|
+
* commands: { execute: ["…"] } → exactly these
|
|
116
|
+
* A plugin may always execute its own commands — that's just calling itself.
|
|
117
|
+
*/
|
|
118
|
+
export function executableCommands(manifest) {
|
|
119
|
+
const opt = capabilityOptions(manifest, 'commands');
|
|
120
|
+
if (!opt) return [];
|
|
121
|
+
if (Array.isArray(opt)) return opt.filter(Boolean);
|
|
122
|
+
if (Array.isArray(opt.execute)) return opt.execute.filter(Boolean);
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether `commandUri` is executable by the plugin described by `manifest`. A plugin
|
|
128
|
+
* always owns its own contributions (the URI is scoped under its domain and name), so
|
|
129
|
+
* ownership is decided by the address itself rather than by a naming convention or a
|
|
130
|
+
* registry lookup that could be raced.
|
|
131
|
+
*/
|
|
132
|
+
export function canExecuteCommand(manifest, commandUri, _ownerPluginId) {
|
|
133
|
+
if (!commandUri) return false;
|
|
134
|
+
if (parseContribUri(commandUri) && ownsUri(manifest, commandUri)) return true;
|
|
135
|
+
return executableCommands(manifest).includes(commandUri);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Parse zip bytes into { manifest, files:Map<path,Uint8Array>, raw }. */
|
|
139
|
+
export function parsePackage(zipBytes) {
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = unzipSync(zipBytes instanceof Uint8Array ? zipBytes : new Uint8Array(zipBytes));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new Error('Not a valid zip archive: ' + err.message);
|
|
145
|
+
}
|
|
146
|
+
const files = new Map();
|
|
147
|
+
for (const [path, bytes] of Object.entries(entries)) {
|
|
148
|
+
if (!path.endsWith('/')) files.set(path.replace(/^\.?\//, ''), bytes);
|
|
149
|
+
}
|
|
150
|
+
const manifestBytes = files.get('manifest.json');
|
|
151
|
+
if (!manifestBytes) throw new Error('Package is missing manifest.json');
|
|
152
|
+
let manifest;
|
|
153
|
+
try {
|
|
154
|
+
manifest = JSON.parse(new TextDecoder().decode(manifestBytes));
|
|
155
|
+
} catch (err) {
|
|
156
|
+
throw new Error('manifest.json is not valid JSON: ' + err.message);
|
|
157
|
+
}
|
|
158
|
+
validateManifest(manifest, files);
|
|
159
|
+
return { manifest, files, raw: zipBytes };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateManifest(m, files) {
|
|
163
|
+
const need = (c, msg) => { if (!c) throw new Error(msg); };
|
|
164
|
+
// Identity is mandatory: a package must say which DOMAIN it belongs to and its NAME
|
|
165
|
+
// within that domain. There is no anonymous/self-identified install — every
|
|
166
|
+
// contribution is addressed under `<domain>/<name>`, and the domain is what makes
|
|
167
|
+
// that address verifiable rather than merely claimed.
|
|
168
|
+
assertIdentity(m);
|
|
169
|
+
need(m.entry, 'manifest.entry (path to the plugin script) is required');
|
|
170
|
+
need(files.has(m.entry), `entry "${m.entry}" is not in the package`);
|
|
171
|
+
if (m.capabilities != null) {
|
|
172
|
+
need(Array.isArray(m.capabilities) || typeof m.capabilities === 'object',
|
|
173
|
+
'capabilities must be an object of { capability: options } (or an array of ids)');
|
|
174
|
+
}
|
|
175
|
+
if (m.icon) need(files.has(m.icon), `icon "${m.icon}" is not in the package`);
|
|
176
|
+
// Throws on a bad type, a missing entry module, or a malformed option — the review
|
|
177
|
+
// dialog must be able to show exactly what will be registered.
|
|
178
|
+
for (const c of declaredContributions(m)) {
|
|
179
|
+
if (c.entry) need(files.has(c.entry), `contribution "${c.name}" points at "${c.entry}", which is not in the package`);
|
|
180
|
+
if (c.type === 'keymap') need(files.has(c.path), `keymap "${c.name}" points at "${c.path}", which is not in the package`);
|
|
181
|
+
}
|
|
182
|
+
for (const ep of networkEndpoints(m)) parseEndpoint(ep); // throws on anything but an http(s) URL
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Fetch a package from a URL (must be a zip). */
|
|
186
|
+
export async function fetchPackage(url, fetchFn = globalThis.fetch.bind(globalThis)) {
|
|
187
|
+
let res;
|
|
188
|
+
try {
|
|
189
|
+
res = await fetchFn(url);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
throw new Error('Could not fetch the plugin: ' + err.message);
|
|
192
|
+
}
|
|
193
|
+
if (!res.ok) throw new Error(`Could not fetch the plugin (HTTP ${res.status})`);
|
|
194
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
195
|
+
return parsePackage(bytes);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A flat, review-friendly summary of everything the package declares. */
|
|
199
|
+
export function reviewSummary(pkg, trust) {
|
|
200
|
+
const m = pkg.manifest;
|
|
201
|
+
const contributions = declaredContributions(m).map((c) => ({
|
|
202
|
+
kind: c.type, name: c.name, uri: c.uri,
|
|
203
|
+
title: c.title || c.name,
|
|
204
|
+
detail: c.type === 'opener' || c.type === 'indexer' ? selectorText(c.match)
|
|
205
|
+
// What it RUNS, not just where it sits. A status item can carry a command, and a
|
|
206
|
+
// review that only said "right of the status bar" gave the user nothing to refuse.
|
|
207
|
+
: c.type === 'statusItem' ? `${c.slot} of the status bar${c.command ? ` — runs ${c.command}` : ''}`
|
|
208
|
+
: c.type === 'keymap' ? c.path : '',
|
|
209
|
+
offline: !!c.offline,
|
|
210
|
+
}));
|
|
211
|
+
const verified = trust?.status === 'verified';
|
|
212
|
+
const scopes = storageScopes(m);
|
|
213
|
+
return {
|
|
214
|
+
id: pluginId(m), name: displayName(m), version: m.version || '0.0.0', description: m.description || '',
|
|
215
|
+
author: m.author || 'Unknown', domain: m.domain,
|
|
216
|
+
capabilities: capabilityList(m).map((cap) => ({ id: cap, description: describeCapability(cap), adminOnly: ADMIN_ONLY_CAPS.has(cap) })),
|
|
217
|
+
contributions,
|
|
218
|
+
settings: (m.settings || []).map((s) => ({ key: s.key, title: s.title || s.key, type: s.type, secret: !!s.secret })),
|
|
219
|
+
network: endpointSummary(networkEndpoints(m)),
|
|
220
|
+
// Exactly which host commands it may run. Resolved to titles by the review UI,
|
|
221
|
+
// which has the command registry; here we just surface the declared ids.
|
|
222
|
+
commands: executableCommands(m),
|
|
223
|
+
// Which SQLite stores it wants. `domain` (shared across a vendor's plugins) is
|
|
224
|
+
// only grantable for a verified package; flag it so the review can say why.
|
|
225
|
+
storage: (scopes.plugin || scopes.domain)
|
|
226
|
+
? { plugin: scopes.plugin, domain: scopes.domain, domainBlocked: scopes.domain && !verified }
|
|
227
|
+
: null,
|
|
228
|
+
fileCount: pkg.files.size,
|
|
229
|
+
sizeBytes: [...pkg.files.values()].reduce((n, b) => n + b.length, 0),
|
|
230
|
+
trust,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The storage scopes actually granted at install: plugin if declared, domain if
|
|
235
|
+
* declared AND the package is domain-verified. */
|
|
236
|
+
export function grantedStorageScopes(manifest, trust) {
|
|
237
|
+
const s = storageScopes(manifest);
|
|
238
|
+
return { plugin: s.plugin, domain: s.domain && trust?.status === 'verified' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function selectorText(sel) {
|
|
242
|
+
if (!sel) return '';
|
|
243
|
+
const parts = [];
|
|
244
|
+
if (sel.ext?.length) parts.push(sel.ext.join(', '));
|
|
245
|
+
if (sel.mime?.length) parts.push(sel.mime.join(', '));
|
|
246
|
+
return parts.join(' · ');
|
|
247
|
+
}
|