@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.17",
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
- // base64 data URL — percent-encoded JS confuses some runtimes' data: loader
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
- this._probe ||= import(/* @vite-ignore */ 'data:text/javascript;base64,' + btoa('export default 1'))
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
- const commandHandlers = new Map();
20
- const openerHandlers = new Map();
21
- let onConnectivity = null, onDeactivate = null, onSettingsChange = null, onDock = null;
22
- const mediaHandlers = {}; // action -> handler, for OS media-session controls
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
- this.type = type;
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: [...commandHandlers.keys()],
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
- const h = commandHandlers.get(params.id);
189
- // Throw, like `opener:open` two lines down. Resolving `undefined` for an id with
190
- // no handler told the host the command had RUN when nothing had.
191
- if (!h) throw new Error(`No handler registered for command "${params.id}"`);
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 f = openerHandlers.get(params.openerId) || openerHandlers.get('*');
198
- if (!f) throw new Error('no opener ' + params.openerId);
199
- return f(params.file, params.context);
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 onDeactivate && onDeactivate();
206
- if (method === 'connectivity') { online = !!params.online; try { onConnectivity && (await onConnectivity({ online })); } catch (e) { console.error(e); } announce(); }
207
- if (method === 'settings:changed') { try { onSettingsChange && onSettingsChange(params.key, params.value); } catch (e) { console.error(e); } }
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') { try { mediaHandlers[params.action] && mediaHandlers[params.action](params); } catch (e) { console.error(e); } }
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') { try { onDock && onDock(params); } catch (e) { console.error(e); } }
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
- handle(name, handler) { commandHandlers.set(name, handler); return this; },
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
- openerHandlers.set(id, handler);
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
- setActionHandler: (action, handler) => { requireCap('media'); if (handler) mediaHandlers[action] = handler; else delete mediaHandlers[action]; return call('media:action', { action, on: !!handler }); },
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
- onChange: (fn) => { onDock = fn; },
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
- onChange: (fn) => { onSettingsChange = fn; },
653
+ /** @deprecated use `ctx.addEventListener('settingschange', …)`. Now additive. */
654
+ onChange: hook(bus, 'settingschange', (e) => [e.detail.key, e.detail.value]),
438
655
  },
439
- onConnectivity: (fn) => { onConnectivity = fn; },
656
+ /** @deprecated use `ctx.addEventListener('connectivity', …)`. Now additive. */
657
+ onConnectivity: hook(bus, 'connectivity'),
440
658
  announce,
441
- onDeactivate: (fn) => { onDeactivate = fn; },
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',