@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/packages/web/dist/sw.js
CHANGED
|
@@ -18,30 +18,60 @@
|
|
|
18
18
|
// API and FILES deliberately do NOT rotate. API is data, not code, and FILES holds the
|
|
19
19
|
// bytes of files the user pinned for offline use — naming it per build would throw away
|
|
20
20
|
// someone's offline library every time the app was redeployed.
|
|
21
|
-
const SHELL = 'trove-shell-
|
|
21
|
+
const SHELL = 'trove-shell-7c8b99c0d0';
|
|
22
22
|
const API = 'trove-api-v1';
|
|
23
23
|
const FILES = 'trove-files-v1';
|
|
24
24
|
const KEEP = new Set([SHELL, API, FILES]);
|
|
25
25
|
|
|
26
26
|
self.addEventListener('install', (e) => {
|
|
27
|
-
|
|
27
|
+
// Nothing is precached in dev — see the fetch handler. Precaching the shell there is
|
|
28
|
+
// precisely how a stale build survives a reload.
|
|
29
|
+
e.waitUntil((isDev
|
|
30
|
+
? Promise.resolve()
|
|
31
|
+
: caches.open(SHELL).then((c) => c.addAll(['/', '/index.html', '/icon.svg']).catch(() => {}))
|
|
32
|
+
).then(() => self.skipWaiting()));
|
|
28
33
|
});
|
|
29
34
|
|
|
30
35
|
self.addEventListener('activate', (e) => {
|
|
31
36
|
e.waitUntil(
|
|
32
37
|
(async () => {
|
|
33
38
|
const names = await caches.keys();
|
|
34
|
-
|
|
39
|
+
// In dev, the shell and API caches are not kept at all — including any left behind
|
|
40
|
+
// by a worker installed before this rule existed, which is the case that otherwise
|
|
41
|
+
// keeps serving yesterday's HTML until someone clears storage by hand.
|
|
42
|
+
const keep = isDev ? new Set([FILES]) : KEEP;
|
|
43
|
+
await Promise.all(names.filter((n) => !keep.has(n)).map((n) => caches.delete(n)));
|
|
35
44
|
await self.clients.claim();
|
|
36
45
|
})(),
|
|
37
46
|
);
|
|
38
47
|
});
|
|
39
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A local development origin, where caching CODE is a liability rather than a feature.
|
|
51
|
+
*
|
|
52
|
+
* The shell and the plugin packages are cached by CONTENT, and in production that is
|
|
53
|
+
* exactly right: assets are fingerprinted, a deploy mints a new shell name, and the old
|
|
54
|
+
* one is swept. In `wrangler dev` the loop is rebuild-and-reload, many times a minute, and
|
|
55
|
+
* a cached shell keeps serving the previous build's HTML — which points at the previous
|
|
56
|
+
* build's bundle. The symptom is the worst kind: a fix that plainly does not take, with no
|
|
57
|
+
* error anywhere, because the page you are looking at is not the page you just built.
|
|
58
|
+
*
|
|
59
|
+
* That cost hours during the audiobook work. So on localhost the worker gets out of the
|
|
60
|
+
* way for everything except FILE bytes — the offline store is the feature under test some
|
|
61
|
+
* of the time, and it is keyed by node id and etag rather than by build.
|
|
62
|
+
*/
|
|
63
|
+
const DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']);
|
|
64
|
+
const isDev = DEV_HOSTS.has(self.location.hostname);
|
|
65
|
+
|
|
40
66
|
self.addEventListener('fetch', (event) => {
|
|
41
67
|
const req = event.request;
|
|
42
68
|
const url = new URL(req.url);
|
|
43
69
|
if (url.origin !== self.location.origin || req.method !== 'GET') return; // only same-origin GETs
|
|
44
70
|
|
|
71
|
+
// Dev: code and API responses go straight to the network, uncached. Pinned file bytes
|
|
72
|
+
// still go through, because that machinery is worth exercising locally.
|
|
73
|
+
if (isDev && url.pathname !== '/api/items/download') return;
|
|
74
|
+
|
|
45
75
|
if (url.pathname === '/api/items/download') {
|
|
46
76
|
event.respondWith(pinnedFirst(req));
|
|
47
77
|
return;
|
|
@@ -76,6 +76,14 @@ export const THUMBNAIL_KEY = 'thumbnail';
|
|
|
76
76
|
* @returns {{range?: {start: number, end: number}, src?: string, contentType?: string}|null}
|
|
77
77
|
*/
|
|
78
78
|
export function thumbnailOf(node) {
|
|
79
|
+
// ALREADY RESOLVED, which is how a recents entry carries one. Recents are a snapshot in
|
|
80
|
+
// localStorage rather than a reference — nothing re-reads the node — so the descriptor
|
|
81
|
+
// is stored on the entry itself and this is where it comes back in. Same shape, so a
|
|
82
|
+
// view cannot tell the two apart, which is the point.
|
|
83
|
+
const stored = node?.thumbnail;
|
|
84
|
+
if (typeof stored?.src === 'string' && stored.src) return stored;
|
|
85
|
+
if (Number.isFinite(stored?.range?.start) && Number.isFinite(stored?.range?.end)) return stored;
|
|
86
|
+
|
|
79
87
|
const contributions = node?.contributions;
|
|
80
88
|
if (!contributions) return null;
|
|
81
89
|
for (const contribution of Object.values(contributions)) {
|
|
@@ -28,6 +28,21 @@ export function launcherMode(query) {
|
|
|
28
28
|
return q.startsWith('!') ? 'command' : q.includes('#') ? 'filter' : 'search';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Does this mode put FILES on screen?
|
|
33
|
+
*
|
|
34
|
+
* Asked because a view is a way of drawing files, so anything that decides "can the user
|
|
35
|
+
* switch views" has to know whether files are what is being shown. `!` lists commands —
|
|
36
|
+
* there is no second way to draw a command, and offering a grid/list toggle over them
|
|
37
|
+
* says the results are something they are not.
|
|
38
|
+
*
|
|
39
|
+
* Expressed as a question about the mode rather than a check for `'command'` at the call
|
|
40
|
+
* site, so a future mode that lists something else is handled here, once.
|
|
41
|
+
*/
|
|
42
|
+
export function modeShowsItems(mode) {
|
|
43
|
+
return mode !== 'command';
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
/**
|
|
32
47
|
* Everything you can do to a file, as descriptions.
|
|
33
48
|
*
|
|
@@ -20,6 +20,7 @@ import { ViewportService } from './viewport.js';
|
|
|
20
20
|
import { SpatialNavigationService } from './spatialNav.js';
|
|
21
21
|
import { VoiceSearchService } from './voiceSearch.js';
|
|
22
22
|
import { MediaUrlService } from './mediaUrls.js';
|
|
23
|
+
import { ItemDataService } from './itemData.js';
|
|
23
24
|
import { FileChunks } from './fileChunks.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -81,6 +82,13 @@ export function createPlatform({ baseUrl = '' } = {}) {
|
|
|
81
82
|
// for, or the network keeping nothing. See fileChunks.js — the retention rule is the
|
|
82
83
|
// whole point of it having a name of its own.
|
|
83
84
|
platform.fileChunks = new FileChunks({ api: platform.api, mediaUrls: platform.mediaUrls });
|
|
85
|
+
// Per-plugin, per-item state — a listening position, a last page. Local first, merged
|
|
86
|
+
// with the server's sidecar when there is one. See itemData.js for why it is neither a
|
|
87
|
+
// contribution nor a setting.
|
|
88
|
+
platform.itemData = new ItemDataService({
|
|
89
|
+
api: platform.api,
|
|
90
|
+
actor: () => platform.identity?.get?.()?.principal?.id || 'local',
|
|
91
|
+
});
|
|
84
92
|
platform.plugins = new PluginHost(platform);
|
|
85
93
|
// Commands consult the plugin host to hide/disable plugin commands that aren't
|
|
86
94
|
// available right now (offline, or the plugin isn't responding).
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// A plugin's key/value data for an item, local first and merged with the server.
|
|
2
|
+
//
|
|
3
|
+
// The plugin sees one interface and is told nothing about where the bytes are — see the
|
|
4
|
+
// SDK's `files.data(id)`. That is the point: a viewer saving a listening position should
|
|
5
|
+
// not have to know whether the drive is reachable, and should not lose the position when
|
|
6
|
+
// it is not.
|
|
7
|
+
//
|
|
8
|
+
// SHAPE. Every write lands in a local map immediately, stamped, and is queued. The queue
|
|
9
|
+
// flushes to `/api/items/:id/data` when it can; the server merges into the item's sidecar,
|
|
10
|
+
// which is a CRDT, and answers with the merged view. A read is the local map merged over
|
|
11
|
+
// whatever the server last said.
|
|
12
|
+
//
|
|
13
|
+
// The merge rule is last-write-wins on a wall-clock stamp, with the actor breaking ties —
|
|
14
|
+
// the same rule the sidecar uses, so both ends agree without either being authoritative.
|
|
15
|
+
// Wall clock rather than the sidecar's Lamport counter because these writes originate on
|
|
16
|
+
// devices that have never spoken to each other: two phones, one of them offline for a day.
|
|
17
|
+
// A Lamport clock cannot order those; a timestamp can, imperfectly and good enough for
|
|
18
|
+
// "where was I in this book".
|
|
19
|
+
//
|
|
20
|
+
// PERSISTENCE is localStorage, not IndexedDB. This is a handful of small values per item
|
|
21
|
+
// — a position, a rate, a bookmark — and localStorage is synchronous, which means a write
|
|
22
|
+
// survives the tab being closed a millisecond later. An async store would not.
|
|
23
|
+
|
|
24
|
+
const KEY = 'trove.itemData';
|
|
25
|
+
const FLUSH_MS = 2000;
|
|
26
|
+
const MAX_ENTRIES = 5000;
|
|
27
|
+
|
|
28
|
+
export class ItemDataService {
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} deps
|
|
31
|
+
* @param {import('./api.js').Api} deps.api
|
|
32
|
+
* @param {() => string} [deps.actor] who is writing, for tie-breaks
|
|
33
|
+
*/
|
|
34
|
+
constructor({ api, actor = () => 'local' } = {}) {
|
|
35
|
+
this.api = api;
|
|
36
|
+
this.actor = actor;
|
|
37
|
+
this.local = load(); // scope -> nodeId -> key -> { value, present, at, actor }
|
|
38
|
+
this.pending = new Set(); // `${scope}\0${nodeId}` awaiting a flush
|
|
39
|
+
this.server = new Map(); // `${scope}\0${nodeId}` -> plain object last seen
|
|
40
|
+
this.timer = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Everything this plugin knows about this item.
|
|
45
|
+
*
|
|
46
|
+
* Local over server, because local includes writes the server has not seen yet. A
|
|
47
|
+
* server fetch is kicked off but not waited on: a viewer opening a book should draw
|
|
48
|
+
* immediately from what this device already knows, and correct itself a moment later
|
|
49
|
+
* if another device got further.
|
|
50
|
+
*/
|
|
51
|
+
async get(scope, nodeId) {
|
|
52
|
+
const merged = { ...(this.server.get(k(scope, nodeId)) || {}) };
|
|
53
|
+
for (const [key, cell] of Object.entries(this.local[scope]?.[nodeId] || {})) {
|
|
54
|
+
if (cell.present) merged[key] = cell.value;
|
|
55
|
+
else delete merged[key];
|
|
56
|
+
}
|
|
57
|
+
this.#refresh(scope, nodeId);
|
|
58
|
+
return merged;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Write now, locally; tell the server when there is one. */
|
|
62
|
+
async set(scope, nodeId, key, value) {
|
|
63
|
+
if (!key) return { ok: false };
|
|
64
|
+
this.#put(scope, nodeId, key, { value, present: true });
|
|
65
|
+
return { ok: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async remove(scope, nodeId, key) {
|
|
69
|
+
if (!key) return { ok: false };
|
|
70
|
+
// A tombstone, not a delete: the server may still hold the old value, and an absence
|
|
71
|
+
// has to be a fact with a stamp or the merge will bring it back.
|
|
72
|
+
this.#put(scope, nodeId, key, { value: null, present: false });
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#put(scope, nodeId, key, cell) {
|
|
77
|
+
this.local[scope] ||= {};
|
|
78
|
+
this.local[scope][nodeId] ||= {};
|
|
79
|
+
this.local[scope][nodeId][key] = { ...cell, at: Date.now(), actor: this.actor() };
|
|
80
|
+
save(this.local);
|
|
81
|
+
this.pending.add(k(scope, nodeId));
|
|
82
|
+
this.#schedule();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#schedule() {
|
|
86
|
+
if (this.timer) return;
|
|
87
|
+
// Coalesced: a scrubbing listener writes a position several times a second, and one
|
|
88
|
+
// request per write would be a request per frame.
|
|
89
|
+
this.timer = setTimeout(() => { this.timer = null; this.flush().catch(() => {}); }, FLUSH_MS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Push what is queued and take back what the server merged.
|
|
94
|
+
*
|
|
95
|
+
* A failure keeps the item queued — that is the whole reason writes are local first, and
|
|
96
|
+
* dropping the queue on a flaky network would defeat it. Nothing is retried immediately;
|
|
97
|
+
* the next write, or the next flush, carries it.
|
|
98
|
+
*/
|
|
99
|
+
async flush() {
|
|
100
|
+
for (const id of [...this.pending]) {
|
|
101
|
+
const [scope, nodeId] = id.split('\0');
|
|
102
|
+
const cells = this.local[scope]?.[nodeId];
|
|
103
|
+
if (!cells) { this.pending.delete(id); continue; }
|
|
104
|
+
const entries = Object.entries(cells).map(([key, c]) => ({ key, value: c.value, remove: !c.present }));
|
|
105
|
+
try {
|
|
106
|
+
const res = await this.api.request('POST', `/api/items/${encodeURIComponent(nodeId)}/data`, {
|
|
107
|
+
query: { scope }, body: { entries },
|
|
108
|
+
});
|
|
109
|
+
this.pending.delete(id);
|
|
110
|
+
this.server.set(id, res?.data || {});
|
|
111
|
+
// The local copy has served its purpose once the server has it: keeping it would
|
|
112
|
+
// make a stale local value outlive a newer one written elsewhere.
|
|
113
|
+
delete this.local[scope][nodeId];
|
|
114
|
+
save(this.local);
|
|
115
|
+
} catch {
|
|
116
|
+
// Left queued deliberately. See above.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Ask the server what it has, without blocking the caller that wanted a value. */
|
|
122
|
+
#refresh(scope, nodeId) {
|
|
123
|
+
const id = k(scope, nodeId);
|
|
124
|
+
if (this.inflight?.has(id)) return;
|
|
125
|
+
(this.inflight ||= new Set()).add(id);
|
|
126
|
+
this.api.request('GET', `/api/items/${encodeURIComponent(nodeId)}/data`, { query: { scope } })
|
|
127
|
+
.then((res) => { this.server.set(id, res?.data || {}); })
|
|
128
|
+
.catch(() => {})
|
|
129
|
+
.finally(() => this.inflight.delete(id));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const k = (scope, nodeId) => `${scope}\0${nodeId}`;
|
|
134
|
+
|
|
135
|
+
function load() {
|
|
136
|
+
try {
|
|
137
|
+
const raw = JSON.parse(localStorage.getItem(KEY)) || {};
|
|
138
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
139
|
+
} catch { return {}; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function save(local) {
|
|
143
|
+
try {
|
|
144
|
+
// Bounded, because this is localStorage and a plugin writing per-item state across a
|
|
145
|
+
// large drive would otherwise fill it and start throwing on every write — including
|
|
146
|
+
// the writes of every other feature that shares the quota. Oldest stamps go first.
|
|
147
|
+
const flat = [];
|
|
148
|
+
for (const [scope, nodes] of Object.entries(local)) {
|
|
149
|
+
for (const [nodeId, cells] of Object.entries(nodes)) {
|
|
150
|
+
for (const [key, cell] of Object.entries(cells)) flat.push({ scope, nodeId, key, cell });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (flat.length > MAX_ENTRIES) {
|
|
154
|
+
flat.sort((a, b) => (a.cell.at || 0) - (b.cell.at || 0));
|
|
155
|
+
for (const { scope, nodeId, key } of flat.slice(0, flat.length - MAX_ENTRIES)) {
|
|
156
|
+
delete local[scope][nodeId][key];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
localStorage.setItem(KEY, JSON.stringify(local));
|
|
160
|
+
} catch { /* private mode, or full: the in-memory copy still works for this session */ }
|
|
161
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// activity to 'home' and closing the modal search when a file opens).
|
|
7
7
|
|
|
8
8
|
import { cell } from '../runtime.js';
|
|
9
|
+
import { thumbnailOf } from '../bl/fileType.js';
|
|
9
10
|
|
|
10
11
|
const RECENTS_KEY = 'trove.recents';
|
|
11
12
|
const RECENTS_MAX = 12;
|
|
@@ -126,7 +127,7 @@ export class NavigationService {
|
|
|
126
127
|
*/
|
|
127
128
|
updateTabNode(node) {
|
|
128
129
|
const recents = this.#state.recents.map((r) => (r.id === node.id
|
|
129
|
-
? { ...r, name: node.name, contentType: node.contentType || r.contentType }
|
|
130
|
+
? { ...r, name: node.name, contentType: node.contentType || r.contentType, ...(thumbnailOf(node) ? { thumbnail: thumbnailOf(node) } : {}) }
|
|
130
131
|
: r));
|
|
131
132
|
if (recents.some((r, i) => r !== this.#state.recents[i])) {
|
|
132
133
|
this.#set({ recents });
|
|
@@ -160,7 +161,20 @@ export class NavigationService {
|
|
|
160
161
|
|
|
161
162
|
#pushRecent(node) {
|
|
162
163
|
if (!node) return;
|
|
163
|
-
|
|
164
|
+
// The THUMBNAIL rides along, and it has to, because a recent entry is a snapshot
|
|
165
|
+
// rather than a reference: nothing later re-reads the node, so a tile drawn from
|
|
166
|
+
// this object sees whatever is stored here and nothing else. Without it a recently
|
|
167
|
+
// opened book showed a generic icon while the very same file two rows below showed
|
|
168
|
+
// its cover — the one difference being which group drew it.
|
|
169
|
+
//
|
|
170
|
+
// Only the descriptor, not the image. It is a range into the file (~80 bytes), so a
|
|
171
|
+
// dozen of them is under a kilobyte of localStorage; storing the whole contribution
|
|
172
|
+
// would put four kilobytes per book in there for no gain.
|
|
173
|
+
const thumbnail = thumbnailOf(node);
|
|
174
|
+
const entry = {
|
|
175
|
+
id: node.id, name: node.name, contentType: node.contentType || '', collectionId: node.collectionId,
|
|
176
|
+
...(thumbnail ? { thumbnail } : {}),
|
|
177
|
+
};
|
|
164
178
|
const recents = [entry, ...this.#state.recents.filter((r) => r.id !== node.id)].slice(0, RECENTS_MAX);
|
|
165
179
|
this.#set({ recents });
|
|
166
180
|
saveRecents(recents);
|
|
@@ -165,6 +165,61 @@ export class PluginRpcRouter {
|
|
|
165
165
|
return { bytes: r.bytes.buffer, etag: r.etag, total: r.total };
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// Is the whole file already HERE, in this browser?
|
|
169
|
+
//
|
|
170
|
+
// The question a viewer has to ask before deciding what to draw, and it is cheap on
|
|
171
|
+
// purpose: `status` reads bookkeeping, not bytes, so a plugin can ask on every open
|
|
172
|
+
// without pulling a hundred megabytes to find out the answer is no.
|
|
173
|
+
case 'files:hasLocal': {
|
|
174
|
+
cap('files');
|
|
175
|
+
const st = this.platform.fileChunks.status(params.id);
|
|
176
|
+
return { local: !!st.done, loaded: st.loaded, total: st.total, ratio: st.ratio, filling: st.filling };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The whole file as a real Blob, or null.
|
|
180
|
+
//
|
|
181
|
+
// This exists because of the frame's CSP: `media-src blob: data:` and
|
|
182
|
+
// `connect-src 'none'`, deliberately, so a viewer CANNOT load a media URL — see
|
|
183
|
+
// pluginFrames.js. A Blob it can. So a downloaded book plays by handing the frame
|
|
184
|
+
// the bytes it already paid for, rather than by punching a hole in the sandbox.
|
|
185
|
+
//
|
|
186
|
+
// Null rather than a partial when the file is incomplete: half an MP4 is not a
|
|
187
|
+
// shorter MP4, and a viewer that got one would fail in a way that looks like a
|
|
188
|
+
// decoder bug instead of a missing download.
|
|
189
|
+
case 'files:localBlob': {
|
|
190
|
+
cap('files');
|
|
191
|
+
const st = this.platform.fileChunks.status(params.id);
|
|
192
|
+
if (!st.done || !st.total) return { blob: null };
|
|
193
|
+
const r = await this.platform.fileChunks.read(params.id, { start: 0, end: st.total });
|
|
194
|
+
// A Blob crosses by structured clone WITHOUT copying its bytes — the handle is
|
|
195
|
+
// refcounted — which is why this is a Blob and not the ArrayBuffer `files:bytes`
|
|
196
|
+
// transfers. Transferring would detach the buffer the offline store is holding.
|
|
197
|
+
const node = await this.platform.api.stat(params.id).catch(() => null);
|
|
198
|
+
return { blob: new Blob([r.bytes], { type: node?.node?.contentType || 'application/octet-stream' }) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A plugin's own key/value data for an item — see sidecar/document.js.
|
|
202
|
+
//
|
|
203
|
+
// The scope is derived from the AUTHENTICATED plugin, never from the params: a
|
|
204
|
+
// frame asking for someone else's namespace gets its own. That is the same rule
|
|
205
|
+
// `files:index` follows for contributions, and the reason "scoped" is a boundary
|
|
206
|
+
// rather than a naming convention.
|
|
207
|
+
case 'items:data:get': {
|
|
208
|
+
cap('files');
|
|
209
|
+
return this.platform.itemData.get(pid, params.id);
|
|
210
|
+
}
|
|
211
|
+
case 'items:data:set': {
|
|
212
|
+
cap('files');
|
|
213
|
+
// Written locally FIRST and flushed later, so a viewer that saves a position on a
|
|
214
|
+
// train keeps it, and the merge happens when there is a network again. See
|
|
215
|
+
// platform/itemData.js.
|
|
216
|
+
return this.platform.itemData.set(pid, params.id, params.key, params.value);
|
|
217
|
+
}
|
|
218
|
+
case 'items:data:remove': {
|
|
219
|
+
cap('files');
|
|
220
|
+
return this.platform.itemData.remove(pid, params.id, params.key);
|
|
221
|
+
}
|
|
222
|
+
|
|
168
223
|
// Keeping a file offline, which is a DIFFERENT act from reading it — see
|
|
169
224
|
// fileChunks.js. Until `start` is called, ranging over a file stores nothing.
|
|
170
225
|
case 'files:offline:start': return cap('files'), this.platform.fileChunks.start(params.id);
|
|
@@ -308,7 +363,20 @@ export class PluginRpcRouter {
|
|
|
308
363
|
/** blob:/data: carry their own bytes; anything else must be a declared endpoint. */
|
|
309
364
|
#artworkAllowed(record, src) {
|
|
310
365
|
if (!src) return false;
|
|
311
|
-
if (/^
|
|
366
|
+
if (/^data:image\//i.test(src)) return true;
|
|
367
|
+
// A `blob:` URL from a plugin frame is ALWAYS unusable here, and accepting one was
|
|
368
|
+
// worse than refusing it: the frame runs on an opaque origin, so its object URLs are
|
|
369
|
+
// `blob:null/…`, and this page cannot load another origin's blob. The browser says
|
|
370
|
+
// "Not allowed to load local resource" against the HOST document — a message that
|
|
371
|
+
// names neither the plugin nor the artwork, for a lock-screen image that simply never
|
|
372
|
+
// appeared.
|
|
373
|
+
//
|
|
374
|
+
// There is no version of this that works: a frame cannot mint a URL in this origin.
|
|
375
|
+
// Artwork has to arrive as bytes, which `data:image/…` above is.
|
|
376
|
+
if (/^blob:/i.test(src)) {
|
|
377
|
+
console.warn(`[trove] ${record.id}: media artwork must be a data: URL — a blob: from a sandboxed frame is opaque-origin and cannot be loaded here`);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
312
380
|
return isAllowedUrl(networkEndpoints(record.manifest), src);
|
|
313
381
|
}
|
|
314
382
|
|
|
@@ -153,7 +153,7 @@ export default function launcher(state, ui, opts = {}) {
|
|
|
153
153
|
: null,
|
|
154
154
|
// Which way to look at the drive. Not in the modal search, where the answer is
|
|
155
155
|
// always "the one thing I am about to press Enter on".
|
|
156
|
-
modal ? null : viewSwitcher(state.views, view, ui),
|
|
156
|
+
modal ? null : viewSwitcher(state.views, view, ui, { mode }),
|
|
157
157
|
),
|
|
158
158
|
showResolved ? resolvedBar(resolved) : null,
|
|
159
159
|
// The results belong to the active view — see ui/components/views. The launcher says
|
|
@@ -22,6 +22,7 @@ import { dd } from '../../../runtime.js';
|
|
|
22
22
|
import { icon } from '../../icon.js';
|
|
23
23
|
import { SetSettingAction } from '../../../bl/actions.js';
|
|
24
24
|
import { SETTING } from '../../../bl/views.js';
|
|
25
|
+
import { modeShowsItems } from '../../../bl/launcher.js';
|
|
25
26
|
import { listView } from './list.js';
|
|
26
27
|
import { gridView, gridMove } from './grid.js';
|
|
27
28
|
|
|
@@ -49,7 +50,19 @@ export function registerBuiltinViews(platform) {
|
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
/** The switcher, shown only when there is more than one way to look at this. */
|
|
52
|
-
export function viewSwitcher(slice, current, ui) {
|
|
53
|
+
export function viewSwitcher(slice, current, ui, { mode } = {}) {
|
|
54
|
+
// Two conditions, and the control is furniture without both.
|
|
55
|
+
//
|
|
56
|
+
// 1. The results are FILES. `!` lists commands, and a grid/list toggle over a list of
|
|
57
|
+
// commands is meaningless — see `modeShowsItems`. This is the one that was missing.
|
|
58
|
+
// 2. There is more than one view.
|
|
59
|
+
//
|
|
60
|
+
// Not, note, "more than one view MATCHES these results". A view's `match` is a
|
|
61
|
+
// PREFERENCE, not a restriction — the grid declares `image/*` to be offered first where
|
|
62
|
+
// the results are pictures, and is available everywhere regardless. Filtering by it hid
|
|
63
|
+
// the grid on a drive of audiobooks, which is the opposite of the point. If a view ever
|
|
64
|
+
// needs to say "I cannot draw this", that wants its own field rather than a reused one.
|
|
65
|
+
if (mode !== undefined && !modeShowsItems(mode)) return null;
|
|
53
66
|
const views = slice?.views || [];
|
|
54
67
|
if (views.length < 2) return null;
|
|
55
68
|
return div({ className: 'view-switch' }, ...views.map((v) =>
|