@3sln/trove 0.0.17 → 0.0.19
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/package.json +1 -1
- package/packages/core/src/plugins/runtime.js +61 -5
- package/packages/core/src/sidecar/document.js +60 -2
- package/packages/core/src/sidecar/index.js +28 -1
- package/packages/plugin-sdk/src/browser.js +257 -26
- package/packages/plugin-sdk/src/protocol.js +9 -0
- package/packages/server/src/routes.js +74 -0
- package/packages/web/dist/assets/main-axerf2vw.js +742 -0
- package/packages/web/dist/assets/{main-wpfmmbfd.js.map → main-axerf2vw.js.map} +12 -11
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +33 -3
- package/packages/web/src/bl/fileType.js +8 -0
- package/packages/web/src/bl/launcher.js +15 -0
- package/packages/web/src/platform/index.js +8 -0
- package/packages/web/src/platform/itemData.js +161 -0
- package/packages/web/src/platform/navigation.js +16 -2
- package/packages/web/src/platform/pluginRpc.js +69 -1
- package/packages/web/src/ui/components/launcher.js +1 -1
- package/packages/web/src/ui/components/views/index.js +14 -1
- package/packages/web/dist/assets/main-wpfmmbfd.js +0 -511
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@3sln/trove",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
|
|
6
6
|
"repository": {
|
|
@@ -80,10 +80,7 @@ export class InProcessIndexerRuntime extends IndexerRuntime {
|
|
|
80
80
|
if (!p) {
|
|
81
81
|
const code = spec.files?.[spec.entry];
|
|
82
82
|
if (!code) return Promise.reject(TroveError.invalid(`Indexer "${spec.id}" is missing its entry "${spec.entry}"`));
|
|
83
|
-
|
|
84
|
-
// (they fall back to a text module), whereas base64 imports reliably.
|
|
85
|
-
const url = 'data:text/javascript;base64,' + bytesToBase64(code);
|
|
86
|
-
p = import(/* @vite-ignore */ url).then((mod) => {
|
|
83
|
+
p = importModule(code).then((mod) => {
|
|
87
84
|
const fn = mod.default || mod.index;
|
|
88
85
|
if (typeof fn !== 'function') throw TroveError.invalid(`Indexer "${spec.id}" has no default/index export`);
|
|
89
86
|
return fn;
|
|
@@ -103,7 +100,14 @@ export class InProcessIndexerRuntime extends IndexerRuntime {
|
|
|
103
100
|
* and will not change while the process lives.
|
|
104
101
|
*/
|
|
105
102
|
async probe() {
|
|
106
|
-
|
|
103
|
+
// A REALISTICALLY SIZED module, not `export default 1`. The tiny version answered a
|
|
104
|
+
// question nobody was asking: Bun imports a small data: URL happily and refuses one
|
|
105
|
+
// over ~1.5 KB with ENAMETOOLONG, so a 12-byte probe passed and every real indexer —
|
|
106
|
+
// the audiobook one is 34 KB — failed per file. That is the same silent shape this
|
|
107
|
+
// probe exists to prevent, so it now loads something the size of a real entry.
|
|
108
|
+
const padding = 'x'.repeat(PROBE_BYTES);
|
|
109
|
+
const code = new TextEncoder().encode(`export default 1; //${padding}`);
|
|
110
|
+
this._probe ||= importModule(code)
|
|
107
111
|
.then(() => ({ ok: true }))
|
|
108
112
|
.catch((err) => ({
|
|
109
113
|
ok: false,
|
|
@@ -124,6 +128,58 @@ export class InProcessIndexerRuntime extends IndexerRuntime {
|
|
|
124
128
|
}
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
/**
|
|
132
|
+
* How big a module the probe pretends to load. Larger than any plausible bundled indexer
|
|
133
|
+
* entry (the audiobook one is 34 KB), because the failure being detected is a SIZE limit
|
|
134
|
+
* and a probe under it proves nothing.
|
|
135
|
+
*/
|
|
136
|
+
export const PROBE_BYTES = 64 * 1024;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Import a module from bytes, on whichever runtime this is.
|
|
140
|
+
*
|
|
141
|
+
* The two schemes are exactly complementary, which is why both are here:
|
|
142
|
+
*
|
|
143
|
+
* data: Node takes it at any size (2.6 MB tested). Bun refuses over ~1.5 KB with
|
|
144
|
+
* `NameTooLong` — it resolves the URL as a path, so ENAMETOOLONG.
|
|
145
|
+
* blob: Bun takes it at any size. Node's ESM loader supports exactly `file:`, `data:`
|
|
146
|
+
* and `node:`, so it throws ERR_UNSUPPORTED_ESM_URL_SCHEME.
|
|
147
|
+
*
|
|
148
|
+
* data: first because it needs no cleanup and the same bytes give the same URL, so the
|
|
149
|
+
* engine's module cache makes a re-import free. An object URL is a live registry entry
|
|
150
|
+
* that leaks until revoked, which is the only reason it is the fallback rather than the
|
|
151
|
+
* default.
|
|
152
|
+
*
|
|
153
|
+
* Neither works on workerd. That is what `WorkerLoaderIndexerRuntime` is for, and what
|
|
154
|
+
* `probe()` reports when it is missing.
|
|
155
|
+
*/
|
|
156
|
+
async function importModule(code) {
|
|
157
|
+
// base64 rather than percent-encoded: some runtimes' data: loader treats
|
|
158
|
+
// percent-encoded JS as a text module instead of code.
|
|
159
|
+
try {
|
|
160
|
+
return await import(/* @vite-ignore */ 'data:text/javascript;base64,' + bytesToBase64(code));
|
|
161
|
+
} catch (dataErr) {
|
|
162
|
+
if (typeof URL.createObjectURL !== 'function' || typeof Blob !== 'function') throw dataErr;
|
|
163
|
+
let url;
|
|
164
|
+
try {
|
|
165
|
+
url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }));
|
|
166
|
+
} catch {
|
|
167
|
+
throw dataErr; // report the data: failure — it is the one that describes the runtime
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return await import(/* @vite-ignore */ url);
|
|
171
|
+
} catch {
|
|
172
|
+
throw dataErr;
|
|
173
|
+
} finally {
|
|
174
|
+
// Safe here and only here: an ESM module is fully instantiated by the time the
|
|
175
|
+
// import resolves, and `#load` caches the promise so this URL is never resolved
|
|
176
|
+
// again. Without it every indexer would pin its own source in memory for the life
|
|
177
|
+
// of the process.
|
|
178
|
+
URL.revokeObjectURL?.(url);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
127
183
|
function bytesToBase64(bytes) {
|
|
128
184
|
let bin = '';
|
|
129
185
|
const CHUNK = 0x8000;
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
export const SIDECAR_VERSION = 1;
|
|
23
23
|
|
|
24
24
|
export function emptyDoc(nodeId) {
|
|
25
|
-
return { v: SIDECAR_VERSION, nodeId, clock: 0, tags: {}, comments: {}, subscribers: {} };
|
|
25
|
+
return { v: SIDECAR_VERSION, nodeId, clock: 0, tags: {}, comments: {}, subscribers: {}, data: {} };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// A stamp orders and tie-breaks a write. Higher clock wins; equal clock → higher
|
|
@@ -101,6 +101,53 @@ export function removeTag(doc, name, { actor, at } = {}) {
|
|
|
101
101
|
if (newer(s, cur)) doc.tags[name] = { present: false, value: cur?.value, ...s };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A plugin's own key/value data for this item.
|
|
106
|
+
*
|
|
107
|
+
* SCOPED, and the scope is not the plugin's to choose: the caller passes the plugin id it
|
|
108
|
+
* has already authenticated, exactly as `/api/index/:indexerId` does for contributions.
|
|
109
|
+
* Two plugins writing `position` to the same book are writing two different things, and a
|
|
110
|
+
* flat namespace would make one silently overwrite the other.
|
|
111
|
+
*
|
|
112
|
+
* Same LWW register as a tag, for the same reason: a listener finishes a chapter on their
|
|
113
|
+
* phone and opens the book on a laptop that has been offline for a day. Both wrote. The
|
|
114
|
+
* later write wins, and `newer` breaks a tie the same total, deterministic way it does
|
|
115
|
+
* everywhere else in this document — so both devices converge on the same answer without
|
|
116
|
+
* either having to be authoritative.
|
|
117
|
+
*/
|
|
118
|
+
export function setData(doc, scope, key, value, { actor, at } = {}) {
|
|
119
|
+
if (!scope || !key) return null;
|
|
120
|
+
doc.data ||= {};
|
|
121
|
+
doc.data[scope] ||= {};
|
|
122
|
+
const s = stamp(doc, actor, at);
|
|
123
|
+
const cur = doc.data[scope][key];
|
|
124
|
+
if (newer(s, cur)) doc.data[scope][key] = { present: true, value, ...s };
|
|
125
|
+
return doc.data[scope][key];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Forget one key. A TOMBSTONE rather than a delete, because a delete that removed the
|
|
130
|
+
* entry would be silently undone by any replica that still had the old value — the
|
|
131
|
+
* absence has to be a fact with a stamp, or it cannot win a merge.
|
|
132
|
+
*/
|
|
133
|
+
export function removeData(doc, scope, key, { actor, at } = {}) {
|
|
134
|
+
if (!scope || !key) return;
|
|
135
|
+
doc.data ||= {};
|
|
136
|
+
doc.data[scope] ||= {};
|
|
137
|
+
const s = stamp(doc, actor, at);
|
|
138
|
+
const cur = doc.data[scope][key];
|
|
139
|
+
if (newer(s, cur)) doc.data[scope][key] = { present: false, value: cur?.value, ...s };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What a plugin sees of its own scope: present keys, values only. */
|
|
143
|
+
export function dataOf(doc, scope) {
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const [key, cell] of Object.entries(doc?.data?.[scope] || {})) {
|
|
146
|
+
if (cell?.present) out[key] = cell.value;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
104
151
|
export function subscribe(doc, userId, { muted = false, actor, at } = {}) {
|
|
105
152
|
if (!userId) return;
|
|
106
153
|
const s = stamp(doc, actor ?? userId, at);
|
|
@@ -119,7 +166,7 @@ export function unsubscribe(doc, userId, { actor, at } = {}) {
|
|
|
119
166
|
export function mergeDoc(a, b) {
|
|
120
167
|
if (!a) return structuredCloneSafe(b);
|
|
121
168
|
if (!b) return structuredCloneSafe(a);
|
|
122
|
-
const out = { v: SIDECAR_VERSION, nodeId: a.nodeId || b.nodeId, clock: Math.max(a.clock || 0, b.clock || 0), tags: {}, comments: {}, subscribers: {} };
|
|
169
|
+
const out = { v: SIDECAR_VERSION, nodeId: a.nodeId || b.nodeId, clock: Math.max(a.clock || 0, b.clock || 0), tags: {}, comments: {}, subscribers: {}, data: {} };
|
|
123
170
|
|
|
124
171
|
for (const key of union(a.tags, b.tags)) out.tags[key] = pick(a.tags[key], b.tags[key]);
|
|
125
172
|
// Documents written before the register was removed still carry one, and a merge that
|
|
@@ -131,6 +178,17 @@ export function mergeDoc(a, b) {
|
|
|
131
178
|
}
|
|
132
179
|
for (const key of union(a.subscribers, b.subscribers)) out.subscribers[key] = pick(a.subscribers[key], b.subscribers[key]);
|
|
133
180
|
|
|
181
|
+
// Data is a map of maps — scope, then key — so it merges a level deeper than the rest.
|
|
182
|
+
// Merged per KEY rather than per scope: two devices that each wrote a different key in
|
|
183
|
+
// the same scope must end up with both, and picking a whole scope would drop one.
|
|
184
|
+
out.data = {};
|
|
185
|
+
for (const scope of union(a.data, b.data)) {
|
|
186
|
+
out.data[scope] = {};
|
|
187
|
+
for (const key of union(a.data?.[scope], b.data?.[scope])) {
|
|
188
|
+
out.data[scope][key] = pick(a.data?.[scope]?.[key], b.data?.[scope]?.[key]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
134
192
|
for (const id of union(a.comments, b.comments)) {
|
|
135
193
|
out.comments[id] = mergeComment(a.comments[id], b.comments[id]);
|
|
136
194
|
}
|
|
@@ -8,7 +8,7 @@ import { SidecarStore } from './store.js';
|
|
|
8
8
|
import { SidecarManager } from './manager.js';
|
|
9
9
|
import {
|
|
10
10
|
addComment, editComment, deleteComment, react, setTag, removeTag,
|
|
11
|
-
subscribe, unsubscribe, viewDoc, extractMentions,
|
|
11
|
+
subscribe, unsubscribe, viewDoc, extractMentions, setData, removeData, dataOf,
|
|
12
12
|
} from './document.js';
|
|
13
13
|
import { newId } from '../util.js';
|
|
14
14
|
import { TroveError } from '../errors.js';
|
|
@@ -115,6 +115,33 @@ export class SidecarService {
|
|
|
115
115
|
return this.view(nodeId);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// --- per-plugin item data --------------------------------------------------
|
|
119
|
+
//
|
|
120
|
+
// A viewer's state about an ITEM, which is neither a contribution nor a setting. A
|
|
121
|
+
// contribution is derived from the file and is rewritten whenever it is re-indexed; a
|
|
122
|
+
// setting is per-device, and a listening position that does not follow you to your phone
|
|
123
|
+
// is the one people notice missing. This is the third thing, and the sidecar is where it
|
|
124
|
+
// belongs because the sidecar is already a per-item CRDT.
|
|
125
|
+
//
|
|
126
|
+
// `scope` is passed in by the caller and is NOT the plugin's to choose — the route
|
|
127
|
+
// derives it from the plugin it has already authenticated, the same way
|
|
128
|
+
// `/api/index/:indexerId` derives a contributor namespace.
|
|
129
|
+
|
|
130
|
+
async setData(nodeId, scope, key, value, principal) {
|
|
131
|
+
await this.manager.mutate(nodeId, (doc) => setData(doc, scope, key, value, { actor: principal?.id }));
|
|
132
|
+
return { ok: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async removeData(nodeId, scope, key, principal) {
|
|
136
|
+
await this.manager.mutate(nodeId, (doc) => removeData(doc, scope, key, { actor: principal?.id }));
|
|
137
|
+
return { ok: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One scope's present keys. Never another scope's — see `dataOf`. */
|
|
141
|
+
async data(nodeId, scope) {
|
|
142
|
+
return dataOf(await this.manager.get(nodeId), scope);
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
// --- subscriptions ---------------------------------------------------------
|
|
119
146
|
|
|
120
147
|
async subscribe(nodeId, principal, muted = false) {
|
|
@@ -14,12 +14,140 @@
|
|
|
14
14
|
// protocol.js — this file is injected as text and cannot import it, so
|
|
15
15
|
// protocol.test.js asserts the two stay in step.
|
|
16
16
|
const SDK_PROTOCOL_VERSION = '1.0';
|
|
17
|
+
|
|
18
|
+
// --- events -----------------------------------------------------------------
|
|
19
|
+
//
|
|
20
|
+
// Every hook here used to be one assignment — `onDeactivate = fn`, `onDock = fn`,
|
|
21
|
+
// `mediaHandlers[action] = fn`. Register twice and the first silently vanished, with
|
|
22
|
+
// nothing to report it: the symptom is a timer or an object URL outliving its viewer,
|
|
23
|
+
// noticed much later as a leak. The audiobook player was losing one of two teardowns
|
|
24
|
+
// exactly that way.
|
|
25
|
+
//
|
|
26
|
+
// So the SDK's nodes are EventTargets and handlers are listeners. Registrations
|
|
27
|
+
// accumulate, removal is `removeEventListener`, and a plugin can compose without
|
|
28
|
+
// knowing what else is listening.
|
|
29
|
+
//
|
|
30
|
+
// Inlined rather than imported because this file is injected as TEXT into a sandboxed
|
|
31
|
+
// frame and has no module loader — see the header.
|
|
32
|
+
//
|
|
33
|
+
// Two things a plain CustomEvent cannot express, both needed:
|
|
34
|
+
//
|
|
35
|
+
// waitUntil(p) The host awaits some of these. `opener:open` must not resolve until
|
|
36
|
+
// the viewer has drawn, or "Opening…" is hidden over a blank frame.
|
|
37
|
+
// respondWith(v) A command has a return value that crosses back over the port. First
|
|
38
|
+
// answer wins; a second is a bug in the plugin, not something to pick
|
|
39
|
+
// between silently.
|
|
40
|
+
class TroveEvent extends Event {
|
|
41
|
+
constructor(type, detail) {
|
|
42
|
+
super(type, { cancelable: true });
|
|
43
|
+
this.detail = detail;
|
|
44
|
+
this._waits = [];
|
|
45
|
+
this._answered = false;
|
|
46
|
+
this._answer = undefined;
|
|
47
|
+
}
|
|
48
|
+
waitUntil(p) { this._waits.push(Promise.resolve(p)); }
|
|
49
|
+
respondWith(value) {
|
|
50
|
+
if (this._answered) return;
|
|
51
|
+
this._answered = true;
|
|
52
|
+
this._answer = value;
|
|
53
|
+
if (value && typeof value.then === 'function') this._waits.push(Promise.resolve(value).then((v) => { this._answer = v; }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Dispatch, then wait for every `waitUntil`, then answer.
|
|
59
|
+
*
|
|
60
|
+
* A listener that throws is reported and does not stop the others — dispatch is where
|
|
61
|
+
* resources are released, and one bad handler must not strand the rest.
|
|
62
|
+
*/
|
|
63
|
+
async function fire(target, type, detail) {
|
|
64
|
+
const ev = new TroveEvent(type, detail);
|
|
65
|
+
target.dispatchEvent(ev);
|
|
66
|
+
for (const r of await Promise.allSettled(ev._waits)) {
|
|
67
|
+
if (r.status === 'rejected') console.error('[trove] a "' + type + '" handler failed', r.reason);
|
|
68
|
+
}
|
|
69
|
+
return ev._answer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An EventTarget that knows what is listening.
|
|
74
|
+
*
|
|
75
|
+
* The host needs that: it is how the manifest reports which commands a plugin actually
|
|
76
|
+
* implements, and how media claims an OS control on the first listener and releases it
|
|
77
|
+
* on the last.
|
|
78
|
+
*/
|
|
79
|
+
function makeTarget(onFirst, onLast) {
|
|
80
|
+
const counts = new Map();
|
|
81
|
+
const target = new EventTarget();
|
|
82
|
+
const add = target.addEventListener.bind(target);
|
|
83
|
+
const remove = target.removeEventListener.bind(target);
|
|
84
|
+
target.types = () => [...counts.entries()].filter(([, n]) => n > 0).map(([t]) => t);
|
|
85
|
+
target.listening = (type) => (counts.get(type) || 0) > 0;
|
|
86
|
+
target.addEventListener = (type, fn, opts) => {
|
|
87
|
+
const n = (counts.get(type) || 0) + 1;
|
|
88
|
+
counts.set(type, n);
|
|
89
|
+
if (n === 1 && onFirst) onFirst(type);
|
|
90
|
+
return add(type, fn, opts);
|
|
91
|
+
};
|
|
92
|
+
target.removeEventListener = (type, fn, opts) => {
|
|
93
|
+
const n = Math.max(0, (counts.get(type) || 0) - 1);
|
|
94
|
+
counts.set(type, n);
|
|
95
|
+
if (n === 0 && onLast) onLast(type);
|
|
96
|
+
return remove(type, fn, opts);
|
|
97
|
+
};
|
|
98
|
+
return target;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `onThing(fn)` written over `addEventListener`.
|
|
103
|
+
*
|
|
104
|
+
* Kept because it reads well for the common case and is what every existing plugin
|
|
105
|
+
* says. What changed is that it no longer REPLACES: two calls mean two listeners, and
|
|
106
|
+
* it returns a disposer. A handler that returns a promise keeps the host waiting; one
|
|
107
|
+
* that returns a value answers.
|
|
108
|
+
*/
|
|
109
|
+
function hook(target, type, args = (e) => [e.detail]) {
|
|
110
|
+
return (fn) => {
|
|
111
|
+
if (typeof fn !== 'function') return () => {};
|
|
112
|
+
const listener = (e) => {
|
|
113
|
+
// GUARDED here rather than relying on the host's EventTarget to isolate a throw.
|
|
114
|
+
// Browsers do report an uncaught listener error and carry on, but that is not
|
|
115
|
+
// uniform — Bun stops dispatch — and teardown is the one place where "the rest
|
|
116
|
+
// still ran" has to be a guarantee rather than a hope.
|
|
117
|
+
let out;
|
|
118
|
+
try {
|
|
119
|
+
out = fn(...args(e));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error('[trove] a "' + e.type + '" handler threw', err);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (out && typeof out.then === 'function') e.waitUntil(out);
|
|
125
|
+
if (out !== undefined) e.respondWith(out);
|
|
126
|
+
};
|
|
127
|
+
target.addEventListener(type, listener);
|
|
128
|
+
return () => target.removeEventListener(type, listener);
|
|
129
|
+
};
|
|
130
|
+
}
|
|
17
131
|
let port = null, manifest = null, capabilities = [], storageScopes = {}, online = true, seq = 0, role = 'primary';
|
|
18
132
|
const pending = new Map();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
133
|
+
// EVERY hook is an EventTarget registration — see above for why. `ctx` itself is
|
|
134
|
+
// the target for lifecycle and connectivity; commands, openers and media each get their
|
|
135
|
+
// own so an event type can be a command name or an action without colliding.
|
|
136
|
+
const bus = makeTarget(); // deactivate | connectivity | settingschange | dockchange
|
|
137
|
+
const commandTarget = makeTarget(); // type = the command's short name
|
|
138
|
+
const openerTarget = makeTarget(); // type = the opener's name, or '*'
|
|
139
|
+
// Media tells the HOST which actions to claim from the OS, so registration is observed:
|
|
140
|
+
// the first listener for an action claims it, the last one to go releases it.
|
|
141
|
+
const mediaTarget = makeTarget(
|
|
142
|
+
(action) => { call('media:action', { action, on: true }).catch(() => {}); },
|
|
143
|
+
(action) => { call('media:action', { action, on: false }).catch(() => {}); },
|
|
144
|
+
);
|
|
145
|
+
// A LIST. It was one slot — `onDeactivate = fn` — so a second registration silently
|
|
146
|
+
// discarded the first, and the plugin that lost one had no way to notice: the symptom
|
|
147
|
+
// is a timer or an object URL that outlives the viewer. The audiobook player has two
|
|
148
|
+
// teardowns (a download poller and the transport), which is not exotic; anything with
|
|
149
|
+
// more than one resource has more than one.
|
|
150
|
+
|
|
23
151
|
|
|
24
152
|
const now = () => { try { return Date.now(); } catch { return 0; } };
|
|
25
153
|
|
|
@@ -65,7 +193,13 @@
|
|
|
65
193
|
constructor(id, { size = 0, type = '', etag = null, start = 0, end = null } = {}) {
|
|
66
194
|
super();
|
|
67
195
|
this.id = id;
|
|
68
|
-
|
|
196
|
+
// `_type`, and a getter below. `Blob.prototype.type` is an accessor with no setter,
|
|
197
|
+
// so `this.type = …` THROWS in strict mode — which every module is. That one line
|
|
198
|
+
// meant `ctx.files.blob()` rejected on construction for every plugin that ever
|
|
199
|
+
// called it: no cover art, no container parsing, no streaming, and an error message
|
|
200
|
+
// ("Cannot set property type of #<Blob> which has only a getter") that never left
|
|
201
|
+
// the sandboxed frame it was thrown in.
|
|
202
|
+
this._type = type;
|
|
69
203
|
this.etag = etag;
|
|
70
204
|
// A window on the source. `size` is this window's length, which is what makes
|
|
71
205
|
// `slice()` of a slice behave the way a caller expects.
|
|
@@ -74,6 +208,7 @@
|
|
|
74
208
|
}
|
|
75
209
|
|
|
76
210
|
get size() { return Math.max(0, this._end - this._start); }
|
|
211
|
+
get type() { return this._type; }
|
|
77
212
|
|
|
78
213
|
/**
|
|
79
214
|
* A window on the same source. No bytes move and none need to exist yet.
|
|
@@ -160,7 +295,7 @@
|
|
|
160
295
|
return {
|
|
161
296
|
domain: manifest && manifest.domain, name: manifest && manifest.name,
|
|
162
297
|
online, role, ts: now(),
|
|
163
|
-
handlers:
|
|
298
|
+
handlers: commandTarget.types(),
|
|
164
299
|
};
|
|
165
300
|
}
|
|
166
301
|
const announce = () => port && emit('manifest', buildManifest());
|
|
@@ -185,30 +320,32 @@
|
|
|
185
320
|
|
|
186
321
|
function dispatch(method, params) {
|
|
187
322
|
if (method === 'command:execute') {
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return h(...(params.args || []));
|
|
323
|
+
// Throw rather than resolve `undefined` for an id nobody implements: that told the
|
|
324
|
+
// host the command had RUN when nothing had.
|
|
325
|
+
if (!commandTarget.listening(params.id)) throw new Error(`No handler registered for command "${params.id}"`);
|
|
326
|
+
return fire(commandTarget, params.id, { args: params.args || [] });
|
|
193
327
|
}
|
|
194
328
|
if (method === 'opener:open') {
|
|
195
329
|
// An opener frame boots at that opener's entry module and runs exactly one
|
|
196
330
|
// opener, so an unkeyed onOpen(fn) handler is the normal case.
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
331
|
+
const type = openerTarget.listening(params.openerId) ? params.openerId
|
|
332
|
+
: openerTarget.listening('*') ? '*' : null;
|
|
333
|
+
if (!type) throw new Error('no opener ' + params.openerId);
|
|
334
|
+
// AWAITED, and that is what `waitUntil` is for: `opener:open` must not resolve until
|
|
335
|
+
// the viewer has drawn, or the host hides "Opening…" over a blank frame.
|
|
336
|
+
return fire(openerTarget, type, { file: params.file, context: params.context, openerId: params.openerId });
|
|
200
337
|
}
|
|
201
338
|
if (method === 'manifest') return buildManifest();
|
|
202
339
|
throw new Error('Unknown host call ' + method);
|
|
203
340
|
}
|
|
204
341
|
async function dispatchEvent(method, params) {
|
|
205
|
-
if (method === 'deactivate') return
|
|
206
|
-
if (method === 'connectivity') { online = !!params.online;
|
|
207
|
-
if (method === 'settings:changed')
|
|
342
|
+
if (method === 'deactivate') return fire(bus, 'deactivate', {});
|
|
343
|
+
if (method === 'connectivity') { online = !!params.online; await fire(bus, 'connectivity', { online }); announce(); }
|
|
344
|
+
if (method === 'settings:changed') return fire(bus, 'settingschange', { key: params.key, value: params.value });
|
|
208
345
|
// The OS/host fired a media transport control (play/pause/next/seek…).
|
|
209
|
-
if (method === 'media:action')
|
|
346
|
+
if (method === 'media:action') return fire(mediaTarget, params.action, params);
|
|
210
347
|
// The host docked or undocked this viewer (see ctx.dock).
|
|
211
|
-
if (method === 'dock:state')
|
|
348
|
+
if (method === 'dock:state') return fire(bus, 'dockchange', params);
|
|
212
349
|
}
|
|
213
350
|
|
|
214
351
|
function requireCap(cap) { if (!capabilities.includes(cap)) throw new Error('Plugin lacks capability "' + cap + '"'); }
|
|
@@ -263,7 +400,10 @@
|
|
|
263
400
|
// for what it declared, addressed by id.
|
|
264
401
|
commands: {
|
|
265
402
|
/** Implement a command this plugin's manifest declares, by its short name. */
|
|
266
|
-
|
|
403
|
+
/** Implement a command this plugin's manifest declares, by its short name. */
|
|
404
|
+
handle(name, handler) { hook(commandTarget, name, (e) => e.detail.args)(handler); return this; },
|
|
405
|
+
addEventListener: (...a) => commandTarget.addEventListener(...a),
|
|
406
|
+
removeEventListener: (...a) => commandTarget.removeEventListener(...a),
|
|
267
407
|
/**
|
|
268
408
|
* Run a command. Its OWN commands by short name; anyone else's by full address
|
|
269
409
|
* (a built-in like 'explorer.download', or a `trove+contrib:` URI) — and only
|
|
@@ -275,9 +415,13 @@
|
|
|
275
415
|
* optional — an opener frame runs exactly one opener. */
|
|
276
416
|
onOpen(idOrHandler, maybeHandler) {
|
|
277
417
|
const [id, handler] = typeof idOrHandler === 'function' ? ['*', idOrHandler] : [idOrHandler, maybeHandler];
|
|
278
|
-
|
|
418
|
+
// A handler returning a promise keeps the host waiting — which is what holds
|
|
419
|
+
// "Opening…" on screen until the viewer has actually drawn. See `hook`.
|
|
420
|
+
hook(openerTarget, id, (e) => [e.detail.file, e.detail.context])(handler);
|
|
279
421
|
return this;
|
|
280
422
|
},
|
|
423
|
+
/** The opener target, for `addEventListener('*', e => e.waitUntil(…))`. */
|
|
424
|
+
openers: openerTarget,
|
|
281
425
|
// NOTE: there is no onIndex(). Indexers run on the SERVER (in its isolate
|
|
282
426
|
// runtime), not in this sandbox — indexing has to happen once per upload for the
|
|
283
427
|
// drive, not in whichever tab is open. An indexer's entry module is plain ESM
|
|
@@ -328,7 +472,21 @@
|
|
|
328
472
|
setMetadata: (m) => (requireCap('media'), call('media:metadata', m || {})),
|
|
329
473
|
setPlaybackState: (state) => (requireCap('media'), call('media:playbackState', { state })),
|
|
330
474
|
setPositionState: (p) => (requireCap('media'), call('media:position', p || {})),
|
|
331
|
-
|
|
475
|
+
/**
|
|
476
|
+
* @deprecated use `ctx.media.addEventListener(action, …)`.
|
|
477
|
+
*
|
|
478
|
+
* Additive now, and the RPC that claims the OS control moves with it: the first
|
|
479
|
+
* listener for an action claims it, the last one to go releases it. Passing null
|
|
480
|
+
* removes nothing in particular, so it is a no-op rather than a silent surprise.
|
|
481
|
+
*/
|
|
482
|
+
setActionHandler: (action, handler) => {
|
|
483
|
+
requireCap('media');
|
|
484
|
+
if (!handler) return Promise.resolve({ ok: true });
|
|
485
|
+
hook(mediaTarget, action)(handler);
|
|
486
|
+
return Promise.resolve({ ok: true });
|
|
487
|
+
},
|
|
488
|
+
addEventListener: (...a) => (requireCap('media'), mediaTarget.addEventListener(...a)),
|
|
489
|
+
removeEventListener: (...a) => mediaTarget.removeEventListener(...a),
|
|
332
490
|
clear: () => call('media:clear', {}),
|
|
333
491
|
},
|
|
334
492
|
// Dock — register this viewer to persist as a small floating frame when the
|
|
@@ -339,7 +497,10 @@
|
|
|
339
497
|
enable: (opts) => (requireCap('dock'), call('dock:enable', opts || {})),
|
|
340
498
|
disable: () => (requireCap('dock'), call('dock:disable', {})),
|
|
341
499
|
close: () => call('dock:close', {}),
|
|
342
|
-
|
|
500
|
+
/** @deprecated use `ctx.addEventListener('dockchange', …)`. Now additive. */
|
|
501
|
+
onChange: hook(bus, 'dockchange'),
|
|
502
|
+
addEventListener: (...a) => bus.addEventListener(...a),
|
|
503
|
+
removeEventListener: (...a) => bus.removeEventListener(...a),
|
|
343
504
|
},
|
|
344
505
|
// Network — there is no direct fetch in the sandbox; the host performs the
|
|
345
506
|
// request, but ONLY to endpoints declared in the manifest's `network` list
|
|
@@ -394,6 +555,61 @@
|
|
|
394
555
|
*/
|
|
395
556
|
mediaUrl: (id, opts) => (requireCap('files'), call('files:mediaUrl', Object.assign({ id }, opts))),
|
|
396
557
|
|
|
558
|
+
/**
|
|
559
|
+
* A file's own key/value data, for THIS plugin.
|
|
560
|
+
*
|
|
561
|
+
* The third kind of state a viewer has, and the one there was nowhere to put. A
|
|
562
|
+
* contribution is derived from the file and is rewritten whenever it is
|
|
563
|
+
* re-indexed. A setting is per-device, so a listening position kept there does
|
|
564
|
+
* not follow you to your phone — which is the moment people notice. This is the
|
|
565
|
+
* other thing: state about a user's use of an item.
|
|
566
|
+
*
|
|
567
|
+
* ONE INTERFACE, and it deliberately does not say where the bytes are. A write
|
|
568
|
+
* lands locally at once and reaches the server when there is a server to reach;
|
|
569
|
+
* a read is the merge of both. Two devices that each wrote while apart converge,
|
|
570
|
+
* later write winning, because the sidecar underneath is a CRDT — see
|
|
571
|
+
* core/sidecar/document.js.
|
|
572
|
+
*
|
|
573
|
+
* Scoped to this plugin by the HOST, from the plugin it authenticated. There is
|
|
574
|
+
* no parameter for the scope because there is no version of this where a plugin
|
|
575
|
+
* chooses whose data it is writing.
|
|
576
|
+
*/
|
|
577
|
+
data: (id) => ({
|
|
578
|
+
all: () => (requireCap('files'), call('items:data:get', { id })),
|
|
579
|
+
get: async (key) => (requireCap('files'), (await call('items:data:get', { id }))[key]),
|
|
580
|
+
set: (key, value) => (requireCap('files'), call('items:data:set', { id, key, value })),
|
|
581
|
+
remove: (key) => (requireCap('files'), call('items:data:remove', { id, key })),
|
|
582
|
+
}),
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Is the whole file already in this browser?
|
|
586
|
+
*
|
|
587
|
+
* Cheap — it reads bookkeeping, not bytes — so ask it before deciding what to
|
|
588
|
+
* draw. `{ local, loaded, total, ratio, filling }`: enough to show a progress
|
|
589
|
+
* bar for a download already under way instead of offering to start it again.
|
|
590
|
+
*/
|
|
591
|
+
hasLocal: (id) => (requireCap('files'), call('files:hasLocal', { id })),
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The whole file as a Blob, or null when it is not stored locally.
|
|
595
|
+
*
|
|
596
|
+
* THE WAY MEDIA WORKS IN HERE. This frame's CSP is `connect-src 'none'` and
|
|
597
|
+
* `media-src blob: data:` — deliberately, so a plugin cannot reach the network
|
|
598
|
+
* and cannot be an exfiltration side-channel. An `<audio src="https://…">` is
|
|
599
|
+
* therefore blocked outright, and no amount of URL minting changes that. A Blob
|
|
600
|
+
* is allowed, so a downloaded file plays by being handed over.
|
|
601
|
+
*
|
|
602
|
+
* Null when the file is incomplete rather than a partial Blob: half an MP4 is
|
|
603
|
+
* not a shorter MP4, and a caller handed one would fail like a decoder bug
|
|
604
|
+
* instead of like a missing download. Pair it with `offline.start` and
|
|
605
|
+
* `hasLocal` to get from "not here" to "here".
|
|
606
|
+
*/
|
|
607
|
+
localBlob: async (id) => {
|
|
608
|
+
requireCap('files');
|
|
609
|
+
const { blob } = await call('files:localBlob', { id });
|
|
610
|
+
return blob || null;
|
|
611
|
+
},
|
|
612
|
+
|
|
397
613
|
/**
|
|
398
614
|
* Keeping a file, which is a DIFFERENT act from reading one.
|
|
399
615
|
*
|
|
@@ -434,11 +650,26 @@
|
|
|
434
650
|
get: (key) => call('settings:get', { key }),
|
|
435
651
|
getSecret: (key) => call('settings:getSecret', { key }),
|
|
436
652
|
set: (key, value) => call('settings:set', { key, value }),
|
|
437
|
-
|
|
653
|
+
/** @deprecated use `ctx.addEventListener('settingschange', …)`. Now additive. */
|
|
654
|
+
onChange: hook(bus, 'settingschange', (e) => [e.detail.key, e.detail.value]),
|
|
438
655
|
},
|
|
439
|
-
|
|
656
|
+
/** @deprecated use `ctx.addEventListener('connectivity', …)`. Now additive. */
|
|
657
|
+
onConnectivity: hook(bus, 'connectivity'),
|
|
440
658
|
announce,
|
|
441
|
-
|
|
659
|
+
/**
|
|
660
|
+
* @deprecated use `ctx.addEventListener('deactivate', …)`.
|
|
661
|
+
*
|
|
662
|
+
* Kept, and now additive: registering twice used to discard the first, which is
|
|
663
|
+
* how a viewer ended up leaking one of its two teardowns. Returns a disposer.
|
|
664
|
+
*/
|
|
665
|
+
onDeactivate: hook(bus, 'deactivate'),
|
|
666
|
+
|
|
667
|
+
// The bus itself. `deactivate`, `connectivity`, `settingschange`, `dockchange` —
|
|
668
|
+
// and every one of them composes, which the `onX` forms above now do too because
|
|
669
|
+
// they are written over this.
|
|
670
|
+
addEventListener: (...a) => bus.addEventListener(...a),
|
|
671
|
+
removeEventListener: (...a) => bus.removeEventListener(...a),
|
|
672
|
+
dispatchEvent: (...a) => bus.dispatchEvent(...a),
|
|
442
673
|
};
|
|
443
674
|
}
|
|
444
675
|
|
|
@@ -49,6 +49,15 @@ export const METHODS = {
|
|
|
49
49
|
bytes: 'files:bytes',
|
|
50
50
|
// A minted URL for a media element. See pluginRpc.js for why this one host URL crosses.
|
|
51
51
|
mediaUrl: 'files:mediaUrl',
|
|
52
|
+
// Is the whole file here, and can I have it? The pair a viewer needs because the
|
|
53
|
+
// frame's CSP forbids loading media from a URL — see pluginFrames.js.
|
|
54
|
+
// A file's own key/value data for this plugin — local-first, merged with the
|
|
55
|
+
// server opportunistically. See platform/itemData.js.
|
|
56
|
+
dataGet: 'items:data:get',
|
|
57
|
+
dataSet: 'items:data:set',
|
|
58
|
+
dataRemove: 'items:data:remove',
|
|
59
|
+
hasLocal: 'files:hasLocal',
|
|
60
|
+
localBlob: 'files:localBlob',
|
|
52
61
|
offline: {
|
|
53
62
|
start: 'files:offline:start', status: 'files:offline:status',
|
|
54
63
|
cancel: 'files:offline:cancel', remove: 'files:offline:remove',
|