@3sln/trove 0.0.7 → 0.0.9
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/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +141 -145
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +63 -11
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +77 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +169 -43
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// What a plugin package's signature actually means.
|
|
2
|
+
//
|
|
3
|
+
// This is a security classification, not a label. It decided the same four states in two
|
|
4
|
+
// different components — the installed-plugin list and the install-review dialog — with two
|
|
5
|
+
// different sets of words, and the distinction that matters most was the one that drifted:
|
|
6
|
+
//
|
|
7
|
+
// `invalid` is NOT `unverified`. Unverified means nobody signed it, which is the ordinary
|
|
8
|
+
// case for a plugin you got from a friend. Invalid means it WAS signed and the signature
|
|
9
|
+
// does not verify — the bytes are not the bytes that were signed. That is the one state
|
|
10
|
+
// implying someone altered the package, and the list once rendered it in the same amber as
|
|
11
|
+
// the benign case, because the two `if` ladders were maintained separately and one of them
|
|
12
|
+
// was missing a branch.
|
|
13
|
+
//
|
|
14
|
+
// So the classification lives here, once, and the components choose their own wording from
|
|
15
|
+
// `status`. What is a security judgement is in the business layer; how many characters fit
|
|
16
|
+
// in a badge is not.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether a package may be installed at all, and on what terms.
|
|
20
|
+
*
|
|
21
|
+
* Three answers, because the states differ in kind rather than in degree:
|
|
22
|
+
*
|
|
23
|
+
* - `invalid` is REFUSED. It means the package was signed and the signature does not
|
|
24
|
+
* verify — the bytes are not the bytes that were signed. No development workflow
|
|
25
|
+
* produces that; it means the package was altered in transit or at rest. There is
|
|
26
|
+
* nothing for a user to weigh, so they are not asked.
|
|
27
|
+
*
|
|
28
|
+
* - `unverified` is installable but only DELIBERATELY. Nobody signed it, which is the
|
|
29
|
+
* ordinary state of a plugin you are writing: you cannot sign a package you are still
|
|
30
|
+
* changing. So it stays possible, behind an acknowledgement that says what is being
|
|
31
|
+
* given up — no publisher, no proof the code is what its author shipped.
|
|
32
|
+
*
|
|
33
|
+
* - `signed` carries a real signature whose domain does not vouch for the key. That is a
|
|
34
|
+
* claim to a namespace rather than a proof of one, so it is said prominently and left
|
|
35
|
+
* to the user; refusing it would block a valid publisher whose assetlinks are merely
|
|
36
|
+
* misconfigured.
|
|
37
|
+
*
|
|
38
|
+
* This is also what closes namespace squatting. A package claiming another publisher's
|
|
39
|
+
* domain is unverified by construction — the real domain does not publish its key — so it
|
|
40
|
+
* cannot be installed without the user being told exactly that.
|
|
41
|
+
*/
|
|
42
|
+
export function installPolicyFor(trust) {
|
|
43
|
+
const t = describeTrust(trust);
|
|
44
|
+
if (t.status === 'invalid') {
|
|
45
|
+
return {
|
|
46
|
+
status: t.status,
|
|
47
|
+
allowed: false,
|
|
48
|
+
requiresAcknowledgement: false,
|
|
49
|
+
headline: 'This package has been altered',
|
|
50
|
+
detail: `${t.explanation}. It was signed, and the signature does not match its contents — so it is not what its author published. Trove will not install it.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (t.status === 'unverified') {
|
|
54
|
+
return {
|
|
55
|
+
status: t.status,
|
|
56
|
+
allowed: true,
|
|
57
|
+
requiresAcknowledgement: true,
|
|
58
|
+
headline: 'Unsigned — for development only',
|
|
59
|
+
detail: 'Nobody has signed this package, so there is no way to tell who wrote it or whether it has been altered since. Install it only if you built it yourself or you trust wherever you got it from.',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (t.status === 'signed') {
|
|
63
|
+
return {
|
|
64
|
+
status: t.status,
|
|
65
|
+
allowed: true,
|
|
66
|
+
requiresAcknowledgement: false,
|
|
67
|
+
headline: 'Signed, but the domain does not vouch for the key',
|
|
68
|
+
detail: `${t.explanation}. The signature is real; what is unproven is that ${t.domain || 'the publisher'} authorised it.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { status: t.status, allowed: true, requiresAcknowledgement: false, headline: null, detail: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {{status?: string, domain?: string, reason?: string}|null} trust
|
|
76
|
+
* @returns {{status: string, tone: string, icon: string, domain: string|null, explanation: string, severity: number}}
|
|
77
|
+
*/
|
|
78
|
+
export function describeTrust(trust) {
|
|
79
|
+
const t = trust || {};
|
|
80
|
+
switch (t.status) {
|
|
81
|
+
case 'verified':
|
|
82
|
+
return {
|
|
83
|
+
status: 'verified', tone: 'verified', icon: 'check', domain: t.domain || null,
|
|
84
|
+
explanation: t.domain
|
|
85
|
+
? `Signed by a key published at ${t.domain}`
|
|
86
|
+
: 'Signed by a key the publishing domain vouches for',
|
|
87
|
+
severity: 0,
|
|
88
|
+
};
|
|
89
|
+
case 'signed':
|
|
90
|
+
return {
|
|
91
|
+
status: 'signed', tone: 'signed', icon: 'info', domain: t.domain || null,
|
|
92
|
+
explanation: t.reason || 'Signed, but the domain does not vouch for the key',
|
|
93
|
+
severity: 1,
|
|
94
|
+
};
|
|
95
|
+
case 'invalid':
|
|
96
|
+
return {
|
|
97
|
+
status: 'invalid', tone: 'invalid', icon: 'warn', domain: t.domain || null,
|
|
98
|
+
// Said plainly. This is the only state that means tampering rather than absence.
|
|
99
|
+
explanation: t.reason || 'The signature did not verify — this package may have been altered',
|
|
100
|
+
severity: 3,
|
|
101
|
+
};
|
|
102
|
+
default:
|
|
103
|
+
return {
|
|
104
|
+
status: 'unverified', tone: 'unverified', icon: 'warn', domain: t.domain || null,
|
|
105
|
+
explanation: t.reason || 'This plugin is not signed',
|
|
106
|
+
severity: 2,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// View state: what the UI is in the middle of doing.
|
|
2
|
+
//
|
|
3
|
+
// Which keybinding is mid-capture, what has been typed into a dialog that has not been
|
|
4
|
+
// submitted, which capabilities are ticked in a plugin review. None of it is drive state —
|
|
5
|
+
// it is thrown away when the dialog closes — but all of it decides what is on screen, and
|
|
6
|
+
// anything that decides what is on screen is engine state. A component reads it through a
|
|
7
|
+
// query and writes it through an action, the same as everything else.
|
|
8
|
+
//
|
|
9
|
+
// The history is worth keeping because it went wrong twice in the same direction. It began
|
|
10
|
+
// as module-level `let`s mutated in place, with a `rerender()` hook threaded through
|
|
11
|
+
// fourteen modules so the leaf that changed one could poke the root into redrawing — data
|
|
12
|
+
// down, invalidation back up out of band. Moving it into a cell fixed the invalidation and
|
|
13
|
+
// left a second version of the same mistake: components still READ it by calling the module
|
|
14
|
+
// during their own render, and two of them WROTE to it there as well, to lazily initialise a
|
|
15
|
+
// default. A render that writes state is a render with a side effect.
|
|
16
|
+
//
|
|
17
|
+
// So this is a resource now, and the lazy-initialise trick is gone: where a component used
|
|
18
|
+
// to write a default on first render, it now computes one and writes nothing until the user
|
|
19
|
+
// does something. `ref` is what makes that possible — it records which dialog instance the
|
|
20
|
+
// value belongs to, so a stale entry is simply ignored rather than needing to be cleared.
|
|
21
|
+
//
|
|
22
|
+
// The holder itself is a slice in bl/state.js; what stays here is how to read one.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The slice for `key`, or `fallback` when it belongs to a different dialog instance.
|
|
26
|
+
*
|
|
27
|
+
* Two components keep a draft that must reset when the dialog is reopened. They used to do
|
|
28
|
+
* that by writing the default during the render that noticed the mismatch. Deriving it
|
|
29
|
+
* instead means the render stays a pure function of the state it was given, and the first
|
|
30
|
+
* write happens when the user actually changes something.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} slice the view-state slice from the query
|
|
33
|
+
* @param {string} key
|
|
34
|
+
* @param {object} ref the dialog instance the value must belong to
|
|
35
|
+
* @param {any} fallback
|
|
36
|
+
*/
|
|
37
|
+
export function draftFor(slice, key, ref, fallback) {
|
|
38
|
+
const held = slice?.[key];
|
|
39
|
+
return held && held.ref === ref ? held : { ref, ...fallback };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where a prompt dialog's typed value lives while it is being typed. */
|
|
43
|
+
export const PROMPT = 'promptValue';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What has been typed into the open prompt, or its initial value.
|
|
47
|
+
*
|
|
48
|
+
* The prompt kept this in a `let` closed over by the render and mutated by the input
|
|
49
|
+
* handler, and handed it back through an `onSubmit` callback — which meant a FUNCTION was
|
|
50
|
+
* living in the dialog spec, and the dialog spec is engine state. Keeping the value here
|
|
51
|
+
* instead lets a prompt carry actions like everything else, and each action reads what was
|
|
52
|
+
* typed rather than being handed it.
|
|
53
|
+
*
|
|
54
|
+
* `ref` ties it to the dialog instance, so reopening starts from the new initial value
|
|
55
|
+
* rather than whatever the last prompt left behind.
|
|
56
|
+
*/
|
|
57
|
+
export function promptValueOf(slice, dialog) {
|
|
58
|
+
const held = slice?.[PROMPT];
|
|
59
|
+
return held && held.ref === dialog ? held.value : (dialog?.value ?? '');
|
|
60
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Which of the registered views should draw the results.
|
|
2
|
+
//
|
|
3
|
+
// The reactive half — which views exist, their when-clauses, which one the user pinned —
|
|
4
|
+
// is the `views` query. This is the rest: arithmetic over the items on screen.
|
|
5
|
+
//
|
|
6
|
+
// Still a plain function rather than part of that query only because the launcher assembles
|
|
7
|
+
// the items mid-render. They are themselves derived from the explorer and search state, so
|
|
8
|
+
// this folds into a query as soon as that assembly does.
|
|
9
|
+
//
|
|
10
|
+
// It lived in ui/components/views/index.js, which was half a job: the registry reads moved
|
|
11
|
+
// into the engine and the DECISION stayed in the render layer. Choosing a layout from what
|
|
12
|
+
// a collection contains is a claim about the drive, not about markup.
|
|
13
|
+
|
|
14
|
+
/** Where a pinned view choice is remembered. */
|
|
15
|
+
export const SETTING = 'explorer.view';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The view to draw with, in order of how much someone meant it.
|
|
19
|
+
*
|
|
20
|
+
* 1. A saved choice — they pressed a button. Nothing infers its way past that.
|
|
21
|
+
* 2. The search transformer's hint, when there is a search on screen. It read the
|
|
22
|
+
* sentence: "photos from the trip last summer" is a request for a gallery as much as
|
|
23
|
+
* it is a query, and nothing downstream can recover that from a list of content types.
|
|
24
|
+
* 3. A view whose `match` suits what is actually there — a collection of photographs
|
|
25
|
+
* opens as a grid without anyone asking.
|
|
26
|
+
* 4. The highest priority, which is the list.
|
|
27
|
+
*
|
|
28
|
+
* A hint naming a view this build doesn't have is ignored, not an error: the transformer
|
|
29
|
+
* is deployment configuration and may outlive the build it was written against.
|
|
30
|
+
*
|
|
31
|
+
* PURE. It used to take `platform` and read the contribution registry, the context keys and
|
|
32
|
+
* the settings mid-render — three reads that nothing invalidated on, so the launcher only
|
|
33
|
+
* kept up because the shell's snapshot was coarse enough to redraw it anyway. The reactive
|
|
34
|
+
* half is the `views` query now; what is left is arithmetic over the items already on
|
|
35
|
+
* screen, which is the one input that cannot be a query (there is no sensible key for "these
|
|
36
|
+
* forty search results").
|
|
37
|
+
*
|
|
38
|
+
* @param {{views: Array, saved: string|null}} slice from the `views` query
|
|
39
|
+
* @param {Array} items the rows being drawn, `{ node }`-shaped
|
|
40
|
+
* @param {string|null} hint the search transformer's suggestion, if this is a search
|
|
41
|
+
*/
|
|
42
|
+
export function pickView({ views = [], saved = null } = {}, items = [], hint = null) {
|
|
43
|
+
if (!views.length) return null;
|
|
44
|
+
const chosen = saved && views.find((v) => v.id === saved);
|
|
45
|
+
if (chosen) return chosen;
|
|
46
|
+
const suggested = hint && views.find((v) => v.id === hint);
|
|
47
|
+
if (suggested) return suggested;
|
|
48
|
+
const nodes = items.map((i) => i.node).filter(Boolean);
|
|
49
|
+
if (nodes.length >= 3) {
|
|
50
|
+
const suits = (v) => v.match && Object.keys(v.match).length
|
|
51
|
+
&& nodes.filter((n) => matchesView(v, n)).length / nodes.length > 0.6;
|
|
52
|
+
const fitted = views.find(suits);
|
|
53
|
+
if (fitted) return fitted;
|
|
54
|
+
}
|
|
55
|
+
return views[0];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchesView(view, node) {
|
|
59
|
+
const ct = node.contentType || '';
|
|
60
|
+
const name = (node.name || '').toLowerCase();
|
|
61
|
+
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
|
62
|
+
if ((view.match.ext || []).includes(ext)) return true;
|
|
63
|
+
return (view.match.mime || []).some((m) => (m.endsWith('/*') ? ct.startsWith(m.slice(0, -1)) : ct === m));
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Reading an engine query from the render layer.
|
|
2
|
+
//
|
|
3
|
+
// Mostly this exists to be the ONE place that knows how a query reaches a component. The
|
|
4
|
+
// bridging itself is a single call; the value is that it is not re-derived at fifteen call
|
|
5
|
+
// sites, and that if it ever needs an initial value or an error view, there is somewhere to
|
|
6
|
+
// put it.
|
|
7
|
+
//
|
|
8
|
+
// `engine.query(q)` answers an observable — `subscribe({next, error, complete})` — and dodo's
|
|
9
|
+
// `watch` takes a Cell — `onDirty(fn)` plus `getValue()`. `fromObservable` already bridges
|
|
10
|
+
// exactly those two shapes, so this is thin on purpose: the point is that there is ONE place
|
|
11
|
+
// that knows how a query reaches a component, rather than the adapter being re-derived at
|
|
12
|
+
// every call site.
|
|
13
|
+
//
|
|
14
|
+
// The subscription belongs to the CELL, not to the component: dodo connects a cell when its
|
|
15
|
+
// first watcher attaches and disconnects when the last one leaves, which is the same
|
|
16
|
+
// lifetime ngin gives a query realization. So a region that scrolls out of the tree stops
|
|
17
|
+
// observing, the query's `kill` runs, and whatever it was holding is released — without any
|
|
18
|
+
// component writing teardown.
|
|
19
|
+
//
|
|
20
|
+
// See docs/tickets/009.
|
|
21
|
+
|
|
22
|
+
import { fromObservable, PENDING } from '../runtime.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cache the bridged cell per (engine, query instance).
|
|
26
|
+
*
|
|
27
|
+
* Not, as this first claimed, because two cells would "undo ngin's sharing" — they would
|
|
28
|
+
* not. Measured: two independent `fromObservable(engine.query(q))` over one instance boot
|
|
29
|
+
* the query ONCE and kill it once, because ngin shares the realization by instance and does
|
|
30
|
+
* not care how many observers arrive or through what. The sharing was never at risk.
|
|
31
|
+
*
|
|
32
|
+
* What the cache actually buys is smaller and worth stating accurately:
|
|
33
|
+
*
|
|
34
|
+
* - **Idempotence.** `watchQuery` can be called freely and returns the same cell, so it
|
|
35
|
+
* is safe in a render. Without it, `watch` — which compares its source by identity and
|
|
36
|
+
* resubscribes when it changes — would tear down and re-establish every pass.
|
|
37
|
+
* - **One fan-out per change** instead of one per bridge. Each extra cell is another
|
|
38
|
+
* observer on the controller and another invalidation to propagate.
|
|
39
|
+
*
|
|
40
|
+
* Neither is correctness, so this is an optimisation and a convenience, not a safeguard.
|
|
41
|
+
* The safeguard against duplicate realizations is interning the query instance — see
|
|
42
|
+
* bl/intern.js, which is where that problem is actually solved.
|
|
43
|
+
*
|
|
44
|
+
* Weak on the engine so a disposed engine takes its cells with it, and weak on the query so
|
|
45
|
+
* an interned instance that falls out of use does too.
|
|
46
|
+
*/
|
|
47
|
+
const cells = new WeakMap();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} engine
|
|
51
|
+
* @param {object} queryInstance an INTERNED instance — see bl/intern.js
|
|
52
|
+
* @returns {object} a dodo Cell, PENDING until the query produces its first value
|
|
53
|
+
*/
|
|
54
|
+
export function watchQuery(engine, queryInstance) {
|
|
55
|
+
let forEngine = cells.get(engine);
|
|
56
|
+
if (!forEngine) {
|
|
57
|
+
forEngine = new WeakMap();
|
|
58
|
+
cells.set(engine, forEngine);
|
|
59
|
+
}
|
|
60
|
+
let cell = forEngine.get(queryInstance);
|
|
61
|
+
if (!cell) {
|
|
62
|
+
// A query may declare what it means before it knows — `initial = null` for something
|
|
63
|
+
// fetched over the network, say. Without one the cell is PENDING, and PENDING reaching
|
|
64
|
+
// a `watch` renders its placeholder: fine for a region that can show an empty bar for a
|
|
65
|
+
// frame, wrong for a snapshot feeding the whole shell, which would blank until the
|
|
66
|
+
// request came back. The initial belongs to the query rather than to this call, so it
|
|
67
|
+
// cannot differ between two call sites reading the same thing.
|
|
68
|
+
const initial = 'initial' in queryInstance ? queryInstance.initial : PENDING;
|
|
69
|
+
cell = fromObservable(engine.query(queryInstance), { initial });
|
|
70
|
+
forEngine.set(queryInstance, cell);
|
|
71
|
+
}
|
|
72
|
+
return cell;
|
|
73
|
+
}
|
|
@@ -238,6 +238,28 @@ export class TroveApiClient {
|
|
|
238
238
|
return this.request('POST', '/api/collections', { body });
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** Change a collection's settings — including turning encryption on. */
|
|
242
|
+
updateCollection(id, patch) {
|
|
243
|
+
return this.request('PATCH', `/api/collections/${encodeURIComponent(id)}`, { body: patch });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- key rotation ---------------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// What a rotation would cost before anyone starts one: on a metered store it is a real
|
|
249
|
+
// bill, and the button should not be the first place that becomes apparent.
|
|
250
|
+
rotationEstimate(id) {
|
|
251
|
+
return this.request('GET', `/api/collections/${encodeURIComponent(id)}/rotate/estimate`);
|
|
252
|
+
}
|
|
253
|
+
rotationState(id) {
|
|
254
|
+
return this.request('GET', `/api/collections/${encodeURIComponent(id)}/rotate`);
|
|
255
|
+
}
|
|
256
|
+
beginRotation(id) {
|
|
257
|
+
return this.request('POST', `/api/collections/${encodeURIComponent(id)}/rotate`);
|
|
258
|
+
}
|
|
259
|
+
cancelRotation(id) {
|
|
260
|
+
return this.request('DELETE', `/api/collections/${encodeURIComponent(id)}/rotate`);
|
|
261
|
+
}
|
|
262
|
+
|
|
241
263
|
// --- conversations, tags, sidecar ------------------------------------------
|
|
242
264
|
sidecar(id) {
|
|
243
265
|
return this.request('GET', `/api/items/${encodeURIComponent(id)}/sidecar`);
|
|
@@ -387,32 +409,57 @@ export class TroveApiClient {
|
|
|
387
409
|
async upload(file, opts) {
|
|
388
410
|
const name = opts.name || file.name || 'untitled';
|
|
389
411
|
const size = file.size;
|
|
390
|
-
|
|
391
|
-
|
|
412
|
+
// Every step of an upload is retried, not just the parts.
|
|
413
|
+
//
|
|
414
|
+
// Multipart parts already had this; nothing else did — so a transient failure on
|
|
415
|
+
// `create`, on the single presigned PUT, or on `complete` killed the whole upload on
|
|
416
|
+
// the first stumble, while the same failure mid-multipart was shrugged off. The
|
|
417
|
+
// single-PUT path is the one most uploads take (anything under the multipart floor),
|
|
418
|
+
// which is to say the retries were on the rarer half.
|
|
419
|
+
//
|
|
420
|
+
// `withRetry` decides what is worth retrying — see errors.js. Notably a lost upload
|
|
421
|
+
// session is NOT retryable, and must not be: a session that has genuinely expired or
|
|
422
|
+
// been aborted will never come back, and hammering it just delays the error. That
|
|
423
|
+
// case is what the manual retry is for.
|
|
424
|
+
const step = (fn) => withRetry(fn, {
|
|
392
425
|
signal: opts.signal,
|
|
426
|
+
retries: 4,
|
|
427
|
+
onRetry: ({ attempt, delayMs, error }) => opts.onRetry?.({ attempt, delayMs, message: error.message }),
|
|
393
428
|
});
|
|
429
|
+
|
|
430
|
+
const plan = await step(() => this.request('POST', `${this.#scope(opts.collection)}/uploads`, {
|
|
431
|
+
body: { name, size, contentType: file.type || undefined },
|
|
432
|
+
signal: opts.signal,
|
|
433
|
+
}));
|
|
394
434
|
// Hand the caller the server upload id so a cancel/failure can abort the session
|
|
395
435
|
// (otherwise a multipart upload leaks server + storage state).
|
|
396
436
|
if (plan.uploadId) opts.onStart?.(plan.uploadId);
|
|
397
437
|
|
|
438
|
+
// Progress is measured against what TRAVELS, which is now the file itself even for an
|
|
439
|
+
// encrypted collection: the drive seals it on the way to the store, so nothing larger
|
|
440
|
+
// than the file ever leaves this tab.
|
|
398
441
|
const progress = new ProgressAggregator(size, opts.onProgress);
|
|
399
442
|
const t = plan.transfer || {};
|
|
400
443
|
const completeUrl = plan.endpoints?.complete || `/api/uploads/${plan.uploadId}/complete`;
|
|
401
444
|
|
|
402
445
|
if (plan.strategy === 'single') {
|
|
403
|
-
// One presigned PUT straight to storage (bytes never touch our server).
|
|
404
|
-
|
|
405
|
-
|
|
446
|
+
// One presigned PUT straight to storage (bytes never touch our server). The plan
|
|
447
|
+
// only ever chooses this for an UNENCRYPTED collection now — a presigned PUT of a
|
|
448
|
+
// file this client has not sealed would put plaintext in the bucket.
|
|
449
|
+
const body = file;
|
|
450
|
+
await step(() => xhrPut(t.url || plan.url, body, { headers: t.requiredHeaders, signal: opts.signal, onProgress: (l) => progress.set('single', l) }));
|
|
451
|
+
const done = await step(() => this.request('POST', completeUrl, { body: {}, signal: opts.signal }));
|
|
406
452
|
return done.node;
|
|
407
453
|
}
|
|
408
454
|
if (plan.strategy === 'direct-single') {
|
|
409
|
-
|
|
455
|
+
const body = file;
|
|
456
|
+
await step(() => xhrPut(this.baseUrl + this.#partUrl(plan, 1), body, {
|
|
410
457
|
// Our own server, so it needs our bearer token; `t.authHeaders` lets the plan
|
|
411
458
|
// add its own and wins on a clash.
|
|
412
459
|
headers: { ...this.authHeaders(), ...t.authHeaders },
|
|
413
460
|
signal: opts.signal, onProgress: (l) => progress.set(1, l),
|
|
414
|
-
});
|
|
415
|
-
const done = await this.request('POST', completeUrl, { body: {}, signal: opts.signal });
|
|
461
|
+
}));
|
|
462
|
+
const done = await step(() => this.request('POST', completeUrl, { body: {}, signal: opts.signal }));
|
|
416
463
|
return done.node;
|
|
417
464
|
}
|
|
418
465
|
|
|
@@ -459,7 +506,11 @@ export class TroveApiClient {
|
|
|
459
506
|
() => this.#uploadPart(plan, n, blob, {
|
|
460
507
|
signal: inner.signal, onProgress: (l) => progress.set(n, l),
|
|
461
508
|
}),
|
|
462
|
-
{
|
|
509
|
+
{
|
|
510
|
+
signal: inner.signal,
|
|
511
|
+
retries: 4,
|
|
512
|
+
onRetry: ({ attempt, delayMs, error }) => opts.onRetry?.({ attempt, delayMs, part: n, message: error.message }),
|
|
513
|
+
},
|
|
463
514
|
);
|
|
464
515
|
results[n - 1] = { partNumber: n, etag };
|
|
465
516
|
}
|
|
@@ -474,9 +525,9 @@ export class TroveApiClient {
|
|
|
474
525
|
}
|
|
475
526
|
|
|
476
527
|
const reportedParts = results.filter(Boolean);
|
|
477
|
-
const done = await this.request('POST', completeUrl, {
|
|
528
|
+
const done = await step(() => this.request('POST', completeUrl, {
|
|
478
529
|
body: { parts: reportedParts }, signal: opts.signal,
|
|
479
|
-
});
|
|
530
|
+
}));
|
|
480
531
|
return done.node;
|
|
481
532
|
}
|
|
482
533
|
|
|
@@ -486,6 +537,7 @@ export class TroveApiClient {
|
|
|
486
537
|
return tmpl.replace('{partNumber}', String(n));
|
|
487
538
|
}
|
|
488
539
|
|
|
540
|
+
|
|
489
541
|
async #uploadPart(plan, n, blob, { signal, onProgress }) {
|
|
490
542
|
const t = plan.transfer || {};
|
|
491
543
|
if (plan.strategy === 'presign') {
|
|
@@ -15,16 +15,30 @@ export class CommandService {
|
|
|
15
15
|
this.contributions = contributions;
|
|
16
16
|
this.context = context;
|
|
17
17
|
this.notifications = notifications;
|
|
18
|
-
this.handlers = new Map(); // id ->
|
|
18
|
+
this.handlers = new Map(); // id -> (...args) => Action | Action[] | null
|
|
19
|
+
// How a resolved command reaches the engine. Filled in by createApp, because this
|
|
20
|
+
// service is built with the rest of the platform and the engine does not exist yet.
|
|
21
|
+
//
|
|
22
|
+
// It is a seam rather than a smell: keystrokes and plugin RPC arrive from outside the
|
|
23
|
+
// engine and something has to carry them in. Until it is set, a command says so instead
|
|
24
|
+
// of silently doing nothing.
|
|
25
|
+
this.dispatch = () => {
|
|
26
|
+
throw new Error('CommandService has no dispatcher yet — see createApp');
|
|
27
|
+
};
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
|
-
* Register a command. Accepts either register(id,
|
|
23
|
-
*
|
|
31
|
+
* Register a command. Accepts either register(id, actions) or a full spec object
|
|
32
|
+
* { id, title, actions, category, icon, when, palette }.
|
|
33
|
+
*
|
|
34
|
+
* `actions` is a PURE FACTORY: `(...args) => Action | Action[] | null`. It is not a
|
|
35
|
+
* handler, and the difference is the point — a handler did the work, which meant every
|
|
36
|
+
* menu item, keybinding and palette entry a person triggered went around the engine. A
|
|
37
|
+
* factory only says what should be dispatched, and `execute` dispatches it.
|
|
24
38
|
*/
|
|
25
|
-
register(idOrSpec,
|
|
26
|
-
const spec = typeof idOrSpec === 'string' ? { id: idOrSpec,
|
|
27
|
-
if (spec.
|
|
39
|
+
register(idOrSpec, actions) {
|
|
40
|
+
const spec = typeof idOrSpec === 'string' ? { id: idOrSpec, actions } : idOrSpec;
|
|
41
|
+
if (spec.actions) this.handlers.set(spec.id, spec.actions);
|
|
28
42
|
const dispose = this.contributions.register(spec.id, {
|
|
29
43
|
type: 'command',
|
|
30
44
|
title: spec.title ?? spec.id,
|
|
@@ -58,9 +72,20 @@ export class CommandService {
|
|
|
58
72
|
return this.availability ? this.availability(cmd) : true;
|
|
59
73
|
}
|
|
60
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Run a command: resolve what it means, then dispatch it.
|
|
77
|
+
*
|
|
78
|
+
* The gating stays here rather than moving into an action, because it is the same three
|
|
79
|
+
* questions for every command however it was triggered — does it exist, is the plugin
|
|
80
|
+
* behind it answering, does its when-clause hold — and answering them in one place is
|
|
81
|
+
* what keeps a keybinding and a palette entry behaving identically.
|
|
82
|
+
*
|
|
83
|
+
* What is NOT here any more is the work. `actions` returns descriptions and this
|
|
84
|
+
* dispatches them, so the engine sees the command AND everything it causes.
|
|
85
|
+
*/
|
|
61
86
|
async execute(id, ...args) {
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
87
|
+
const actionsFor = this.handlers.get(id);
|
|
88
|
+
if (!actionsFor) {
|
|
64
89
|
this.notifications.error(`Command not found: ${id}`);
|
|
65
90
|
return;
|
|
66
91
|
}
|
|
@@ -71,7 +96,22 @@ export class CommandService {
|
|
|
71
96
|
}
|
|
72
97
|
if (!this.isEnabled(id)) return; // gated by when-clause
|
|
73
98
|
try {
|
|
74
|
-
|
|
99
|
+
const actions = actionsFor(...args);
|
|
100
|
+
if (!actions) return;
|
|
101
|
+
// One at a time, each genuinely finished before the next begins.
|
|
102
|
+
//
|
|
103
|
+
// `dispatch` answers an event FEED, not a promise for the work — awaiting it resolves
|
|
104
|
+
// immediately and the action runs afterwards, so a bare `await dispatch(a)` in a loop
|
|
105
|
+
// would start all of them at once while looking like it sequenced them. `next` on the
|
|
106
|
+
// terminal events is the actual completion signal.
|
|
107
|
+
//
|
|
108
|
+
// It resolves with whichever of complete/error/abort fired rather than rejecting, so
|
|
109
|
+
// a failing action does not throw here — but it does stop the rest, because a command
|
|
110
|
+
// whose first step failed rarely wants its second.
|
|
111
|
+
for (const action of [].concat(actions)) {
|
|
112
|
+
const settled = await this.dispatch(action).next(['complete', 'error', 'abort']);
|
|
113
|
+
if (settled?.type !== 'complete') break;
|
|
114
|
+
}
|
|
75
115
|
} catch (err) {
|
|
76
116
|
console.error(`Command ${id} failed`, err);
|
|
77
117
|
this.notifications.error(err?.message || `Command failed: ${id}`);
|