slidemind 0.2.3 → 0.3.0
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +65 -15
- package/README.md +36 -2
- package/bin/slidemind.mjs +1609 -225
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +47 -19
- package/skills/search/SKILL.md +26 -18
- package/src/bridge/build-and-push.mjs +431 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +1214 -107
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/engine-download.mjs +61 -8
- package/src/bridge/graph-auth.mjs +859 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +181 -135
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
package/src/bridge/config.mjs
CHANGED
|
@@ -14,13 +14,24 @@
|
|
|
14
14
|
// The legacy v1 shape ({ backendURL, sessionToken }) is migrated transparently
|
|
15
15
|
// on read, so older installs and the raw-file test writers keep working.
|
|
16
16
|
//
|
|
17
|
+
// A record may additionally carry `msGraphRefreshToken` — the user's own Entra
|
|
18
|
+
// refresh token from `slidemind login` (see graph-auth.mjs). It is a SECOND
|
|
19
|
+
// credential with a different subject: the CLI session is minted by the add-in
|
|
20
|
+
// for whoever clicked Connect CLI, the Graph token belongs to whoever completed
|
|
21
|
+
// the browser sign-in. Written only when present, so a machine that never ran
|
|
22
|
+
// `login` keeps a byte-identical file.
|
|
23
|
+
//
|
|
17
24
|
// Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
|
|
18
25
|
// clearly user-scoped. 0600 — it holds bearer credentials.
|
|
19
26
|
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import crypto from "node:crypto";
|
|
20
29
|
import fs from "node:fs";
|
|
21
30
|
import os from "node:os";
|
|
22
31
|
import path from "node:path";
|
|
23
32
|
|
|
33
|
+
import { register as registerSecret } from "./secrets.mjs";
|
|
34
|
+
|
|
24
35
|
export function sessionConfigDir() {
|
|
25
36
|
// An explicitly set XDG_CONFIG_HOME wins on EVERY platform — it's a
|
|
26
37
|
// deliberate override (and how the test suite isolates the store, also on
|
|
@@ -60,6 +71,46 @@ export function deriveEnvLabel(backendURL) {
|
|
|
60
71
|
return host;
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// How loudly to talk about environments
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
//
|
|
78
|
+
// The env label exists because a slidemind DEVELOPER pairs local / dev / prod at
|
|
79
|
+
// once. A normal user connects exactly one backend, and for them the label on
|
|
80
|
+
// every line is noise that makes ordinary output read like a debugging session.
|
|
81
|
+
// So it is routing metadata, shown only when it actually disambiguates: more
|
|
82
|
+
// than one backend connected.
|
|
83
|
+
//
|
|
84
|
+
// Deliberately NOT gated: `--json` (agents route on it), `doctor` and `whoami`
|
|
85
|
+
// (reporting environments is their whole job), and any label the user just
|
|
86
|
+
// typed — `--backend prod`, `logout dev` — because suppressing an echo of their
|
|
87
|
+
// own argument is confusing, not quiet.
|
|
88
|
+
//
|
|
89
|
+
// Memoised: `presentations` would otherwise re-read the session file once per
|
|
90
|
+
// printed row. `writeAll` clears it, which covers every mutation (setup, login,
|
|
91
|
+
// logout, use) inside a single process.
|
|
92
|
+
let _sessionCount = null;
|
|
93
|
+
|
|
94
|
+
export function isMultiEnv() {
|
|
95
|
+
if (_sessionCount === null) _sessionCount = readSessions().length;
|
|
96
|
+
return _sessionCount > 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** " [dev]" when more than one backend is connected, "" otherwise. */
|
|
100
|
+
export function envTag(env) {
|
|
101
|
+
return env && isMultiEnv() ? ` [${env}]` : "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* An in-sentence env mention — `envPhrase("dev", "for")` → " for dev" — or ""
|
|
106
|
+
* when there is only one environment to mean. Callers word the sentence so it
|
|
107
|
+
* reads correctly either way.
|
|
108
|
+
*/
|
|
109
|
+
export function envPhrase(env, preposition = "on") {
|
|
110
|
+
if (!env || !isMultiEnv()) return "";
|
|
111
|
+
return preposition === "on" ? ` on '${env}'` : ` ${preposition} ${env}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
63
114
|
function isValidRecord(o) {
|
|
64
115
|
return (
|
|
65
116
|
o &&
|
|
@@ -84,6 +135,20 @@ function readRaw() {
|
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
|
|
138
|
+
// Every credential this file hands out is registered for output scrubbing the
|
|
139
|
+
// moment it is read, so a token spliced into an error message downstream — a
|
|
140
|
+
// server echoing the request back, a response body used as an error detail —
|
|
141
|
+
// renders as an opaque tag instead of the value. Reading the session file is
|
|
142
|
+
// the single door every session token comes through, so this is the one place
|
|
143
|
+
// that has to remember. The `@keychain` marker is a literal, not a secret.
|
|
144
|
+
function registerRecordSecrets(record) {
|
|
145
|
+
registerSecret(record.sessionToken);
|
|
146
|
+
if (record.msGraphRefreshToken && record.msGraphRefreshToken !== KEYCHAIN_MARKER) {
|
|
147
|
+
registerSecret(record.msGraphRefreshToken);
|
|
148
|
+
}
|
|
149
|
+
return record;
|
|
150
|
+
}
|
|
151
|
+
|
|
87
152
|
// All paired sessions as [{ backendURL, sessionToken, label }]. Migrates the v1
|
|
88
153
|
// single-record shape transparently. Returns [] when not paired / unreadable.
|
|
89
154
|
export function readSessions() {
|
|
@@ -91,14 +156,18 @@ export function readSessions() {
|
|
|
91
156
|
if (!obj) return [];
|
|
92
157
|
// v2: { version, sessions: [...], default }
|
|
93
158
|
if (Array.isArray(obj.sessions)) {
|
|
94
|
-
return obj.sessions.filter(isValidRecord).map((s) => ({
|
|
159
|
+
return obj.sessions.filter(isValidRecord).map(registerRecordSecrets).map((s) => ({
|
|
95
160
|
backendURL: s.backendURL,
|
|
96
161
|
sessionToken: s.sessionToken,
|
|
97
162
|
label: typeof s.label === "string" && s.label ? s.label : deriveEnvLabel(s.backendURL),
|
|
163
|
+
...(typeof s.msGraphRefreshToken === "string" && s.msGraphRefreshToken
|
|
164
|
+
? { msGraphRefreshToken: s.msGraphRefreshToken }
|
|
165
|
+
: {}),
|
|
98
166
|
}));
|
|
99
167
|
}
|
|
100
168
|
// v1: { backendURL, sessionToken }
|
|
101
169
|
if (isValidRecord(obj)) {
|
|
170
|
+
registerRecordSecrets(obj);
|
|
102
171
|
return [
|
|
103
172
|
{
|
|
104
173
|
backendURL: obj.backendURL,
|
|
@@ -122,6 +191,7 @@ export function getDefaultBackend() {
|
|
|
122
191
|
}
|
|
123
192
|
|
|
124
193
|
function writeAll(sessions, defaultBackend) {
|
|
194
|
+
_sessionCount = null; // the count just changed — see isMultiEnv()
|
|
125
195
|
fs.mkdirSync(sessionConfigDir(), { recursive: true });
|
|
126
196
|
const payload = {
|
|
127
197
|
version: 2,
|
|
@@ -129,6 +199,7 @@ function writeAll(sessions, defaultBackend) {
|
|
|
129
199
|
backendURL: s.backendURL,
|
|
130
200
|
sessionToken: s.sessionToken,
|
|
131
201
|
label: s.label || deriveEnvLabel(s.backendURL),
|
|
202
|
+
...(s.msGraphRefreshToken ? { msGraphRefreshToken: s.msGraphRefreshToken } : {}),
|
|
132
203
|
})),
|
|
133
204
|
};
|
|
134
205
|
const def =
|
|
@@ -143,6 +214,12 @@ function writeAll(sessions, defaultBackend) {
|
|
|
143
214
|
|
|
144
215
|
// Add or replace a session (keyed by backendURL). Becomes the default if none
|
|
145
216
|
// is set yet. Preserves the existing default otherwise.
|
|
217
|
+
//
|
|
218
|
+
// Any stored `msGraphRefreshToken` is deliberately NOT carried over: redeeming
|
|
219
|
+
// a fresh claim can hand this machine to a different identity, and silently
|
|
220
|
+
// pairing that session with the previous user's delegated Graph token would
|
|
221
|
+
// fetch one person's content under another's session. `slidemind login` again
|
|
222
|
+
// is one command; a mismatched identity is not recoverable by the user.
|
|
146
223
|
export function upsertSession({ backendURL, sessionToken, label = null }) {
|
|
147
224
|
if (!backendURL || !sessionToken) {
|
|
148
225
|
throw new Error("upsertSession: backendURL and sessionToken are required");
|
|
@@ -200,6 +277,121 @@ export function getSessionFor(backendURL) {
|
|
|
200
277
|
return readSessions().find((s) => s.backendURL === backendURL) || null;
|
|
201
278
|
}
|
|
202
279
|
|
|
280
|
+
// --- Graph refresh-token at-rest storage ------------------------------------
|
|
281
|
+
//
|
|
282
|
+
// On macOS the token goes into the login Keychain (encrypted at rest, absent
|
|
283
|
+
// from file backups and accidental config-dir syncs); the session file then
|
|
284
|
+
// carries only the marker below so hasGraphSession stays a file read. Any
|
|
285
|
+
// keychain failure falls back to the plaintext field silently — the 0600 file
|
|
286
|
+
// is the universal path and stays what Linux and Windows use.
|
|
287
|
+
// ponytail: macOS-only at-rest hardening — upgrade path is libsecret on Linux
|
|
288
|
+
// and DPAPI/Credential Manager on Windows, both of which need a dependency.
|
|
289
|
+
//
|
|
290
|
+
// The `security` CLI takes the secret as an argv, which is visible in a process
|
|
291
|
+
// listing for the milliseconds the write takes — to the same user and root,
|
|
292
|
+
// i.e. the same principals who can already read the 0600 fallback file. Judged
|
|
293
|
+
// acceptable against what the keychain buys at rest.
|
|
294
|
+
//
|
|
295
|
+
// Never during tests: `node --test` sets NODE_TEST_CONTEXT, and a test writing
|
|
296
|
+
// into the developer's real login keychain would outlive the test's isolated
|
|
297
|
+
// XDG_CONFIG_HOME. SLIDEMIND_NO_KEYCHAIN is the explicit opt-out.
|
|
298
|
+
const KEYCHAIN_MARKER = "@keychain";
|
|
299
|
+
const KEYCHAIN_SERVICE = "slidemind-msgraph";
|
|
300
|
+
|
|
301
|
+
function keychainUsable() {
|
|
302
|
+
return (
|
|
303
|
+
process.platform === "darwin" &&
|
|
304
|
+
!process.env.NODE_TEST_CONTEXT &&
|
|
305
|
+
!process.env.SLIDEMIND_NO_KEYCHAIN
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Stable per-backend account key. Hashed: keychain account fields end up in
|
|
310
|
+
// `security dump-keychain` output and logs, and the backend URL is exactly the
|
|
311
|
+
// thing user-facing output must never carry.
|
|
312
|
+
function keychainAccount(backendURL) {
|
|
313
|
+
return crypto.createHash("sha256").update(String(backendURL)).digest("hex").slice(0, 32);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function keychainWrite(backendURL, token) {
|
|
317
|
+
execFileSync(
|
|
318
|
+
"security",
|
|
319
|
+
["add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL), "-w", token],
|
|
320
|
+
{ stdio: "ignore" },
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function keychainRead(backendURL) {
|
|
325
|
+
const out = execFileSync(
|
|
326
|
+
"security",
|
|
327
|
+
["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL), "-w"],
|
|
328
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
329
|
+
);
|
|
330
|
+
const token = out.toString("utf8").trim();
|
|
331
|
+
return token || null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function keychainDelete(backendURL) {
|
|
335
|
+
try {
|
|
336
|
+
execFileSync(
|
|
337
|
+
"security",
|
|
338
|
+
["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL)],
|
|
339
|
+
{ stdio: "ignore" },
|
|
340
|
+
);
|
|
341
|
+
} catch {
|
|
342
|
+
/* nothing stored — logout of a file-stored or never-stored token */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The user's stored Entra refresh token for one backend, or null. See
|
|
347
|
+
// graph-auth.mjs — nothing else may read it, and it never leaves this machine
|
|
348
|
+
// except as an Authorization on a token refresh.
|
|
349
|
+
export function getGraphRefreshToken(backendURL) {
|
|
350
|
+
const stored = getSessionFor(backendURL)?.msGraphRefreshToken ?? null;
|
|
351
|
+
if (stored !== KEYCHAIN_MARKER) return stored;
|
|
352
|
+
try {
|
|
353
|
+
// The file held only the marker, so `readSessions` had nothing to register —
|
|
354
|
+
// the real value first exists here.
|
|
355
|
+
const fromKeychain = keychainRead(backendURL);
|
|
356
|
+
registerSecret(fromKeychain);
|
|
357
|
+
return fromKeychain;
|
|
358
|
+
} catch {
|
|
359
|
+
// Marker without a readable keychain item (locked keychain, item removed
|
|
360
|
+
// by hand): behave as signed out rather than erroring every engine op.
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Store (or, with a null token, drop) the Graph refresh token for one backend.
|
|
366
|
+
// Every refresh returns a NEW refresh token, so this is called on each rotation.
|
|
367
|
+
// Returns false when that backend is no longer paired — a session removed
|
|
368
|
+
// mid-refresh must not resurrect itself as a token-only record.
|
|
369
|
+
export function setGraphRefreshToken(backendURL, refreshToken) {
|
|
370
|
+
const sessions = readSessions();
|
|
371
|
+
const record = sessions.find((s) => s.backendURL === backendURL);
|
|
372
|
+
if (!record) return false;
|
|
373
|
+
if (refreshToken) {
|
|
374
|
+
record.msGraphRefreshToken = refreshToken;
|
|
375
|
+
if (keychainUsable()) {
|
|
376
|
+
try {
|
|
377
|
+
keychainWrite(backendURL, refreshToken);
|
|
378
|
+
// Migration is this line: a previously plaintext field becomes the
|
|
379
|
+
// marker on the first successful keychain write.
|
|
380
|
+
record.msGraphRefreshToken = KEYCHAIN_MARKER;
|
|
381
|
+
} catch {
|
|
382
|
+
/* keychain unavailable — the plaintext field above stands */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
if (record.msGraphRefreshToken === KEYCHAIN_MARKER && keychainUsable()) {
|
|
387
|
+
keychainDelete(backendURL);
|
|
388
|
+
}
|
|
389
|
+
delete record.msGraphRefreshToken;
|
|
390
|
+
}
|
|
391
|
+
writeAll(sessions, getDefaultBackend());
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
203
395
|
// Back-compat writer: upsert a single session (used by tests and any v1 caller).
|
|
204
396
|
export function writeSessionConfig({ backendURL, sessionToken }) {
|
|
205
397
|
if (!backendURL || !sessionToken) {
|
|
@@ -141,6 +141,25 @@ export function resolveAssetExt(mimeType, buf) {
|
|
|
141
141
|
return extFromBytes(buf) ?? declared;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// The recolour descriptor, with a pane-free TINT REFUSAL folded in: the bytes
|
|
145
|
+
// on disk are then the original ones, so `applied` has to read false and the
|
|
146
|
+
// note has to name the one route that does bake a colour in.
|
|
147
|
+
function describeRecolorWithRefusal(markup, mimeType, iconColor, refusedColor) {
|
|
148
|
+
const described = describeRecolor(
|
|
149
|
+
markup,
|
|
150
|
+
mimeType,
|
|
151
|
+
refusedColor ? null : (iconColor ?? null),
|
|
152
|
+
);
|
|
153
|
+
if (!refusedColor) return described;
|
|
154
|
+
return {
|
|
155
|
+
...described,
|
|
156
|
+
note:
|
|
157
|
+
`Saved in its ORIGINAL colours — recolouring to ${refusedColor} runs in the slidemind add-in. ` +
|
|
158
|
+
"Re-run this download with --to <presId> (a PowerPoint window with the slidemind pane open) " +
|
|
159
|
+
"to bake the colour in.",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
// Stream one result into assetsDir via the add-in's streaming route.
|
|
145
164
|
//
|
|
146
165
|
// Returns the same shape as `downloadOneResult`, or null when the paired add-in
|
|
@@ -156,21 +175,29 @@ async function streamOneResult({
|
|
|
156
175
|
iconColor,
|
|
157
176
|
}) {
|
|
158
177
|
if (typeof client.engineDownloadToFile !== "function") return null;
|
|
178
|
+
// `presId: null` + a resolved backendURL is the pane-free route (the client
|
|
179
|
+
// picks the direct stream); a presId relays through that window's pane.
|
|
159
180
|
// Write under a provisional name: the true extension is only knowable from the
|
|
160
181
|
// bytes, and buffering the image just to sniff its header would defeat the
|
|
161
182
|
// purpose of streaming.
|
|
162
183
|
const tempPath = path.join(assetsDir, `.engine-${searchId}-${selection}.part`);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
res = await client.engineDownloadToFile({
|
|
184
|
+
const fetchOnce = (color) =>
|
|
185
|
+
client.engineDownloadToFile({
|
|
166
186
|
presId,
|
|
167
187
|
backendURL,
|
|
168
188
|
searchId,
|
|
169
189
|
selection,
|
|
170
190
|
destPath: tempPath,
|
|
171
|
-
iconColor:
|
|
191
|
+
iconColor: color ?? null,
|
|
172
192
|
onProgress: createTransferProgress(`downloading result ${selection}`),
|
|
173
193
|
});
|
|
194
|
+
let res;
|
|
195
|
+
// Set when the pane-free route refused the TINT (not the asset): the download
|
|
196
|
+
// then repeats without a colour so the caller still gets the icon, and the
|
|
197
|
+
// recolour is reported as not applied instead of silently claimed.
|
|
198
|
+
let recolorRefused = null;
|
|
199
|
+
try {
|
|
200
|
+
res = await fetchOnce(iconColor);
|
|
174
201
|
} catch (err) {
|
|
175
202
|
fs.rmSync(tempPath, { force: true });
|
|
176
203
|
// Route absent → this add-in predates streaming; let the caller fall back.
|
|
@@ -178,7 +205,13 @@ async function streamOneResult({
|
|
|
178
205
|
// for a stale selection), not Express saying "no such path" — retrying that
|
|
179
206
|
// on the capped path would just fail again, more slowly.
|
|
180
207
|
if ((err?.status === 404 && !err?.code) || err?.status === 501) return null;
|
|
181
|
-
|
|
208
|
+
// The recolour classifier is DOM-based and lives in the add-in, so the
|
|
209
|
+
// pane-free route refuses a tint rather than guessing at one. Refusing the
|
|
210
|
+
// whole DOWNLOAD over it would be worse: the icon is fetchable, only the
|
|
211
|
+
// colour is not. Take the bytes and say so.
|
|
212
|
+
if (err?.code !== "recolor_needs_host" || !iconColor) throw err;
|
|
213
|
+
recolorRefused = iconColor;
|
|
214
|
+
res = await fetchOnce(null);
|
|
182
215
|
}
|
|
183
216
|
|
|
184
217
|
try {
|
|
@@ -193,7 +226,12 @@ async function streamOneResult({
|
|
|
193
226
|
path: target,
|
|
194
227
|
reference: `./assets/${filename}`,
|
|
195
228
|
mimeType: res.mimeType ?? "image/svg+xml",
|
|
196
|
-
recolor:
|
|
229
|
+
recolor: describeRecolorWithRefusal(
|
|
230
|
+
res.svgMarkup,
|
|
231
|
+
res.mimeType,
|
|
232
|
+
iconColor,
|
|
233
|
+
recolorRefused,
|
|
234
|
+
),
|
|
197
235
|
size: svgSizeFromMarkup(res.svgMarkup),
|
|
198
236
|
};
|
|
199
237
|
}
|
|
@@ -222,7 +260,12 @@ async function streamOneResult({
|
|
|
222
260
|
path: target,
|
|
223
261
|
reference: `./assets/${filename}`,
|
|
224
262
|
mimeType: res.mimeType ?? null,
|
|
225
|
-
recolor:
|
|
263
|
+
recolor: describeRecolorWithRefusal(
|
|
264
|
+
null,
|
|
265
|
+
res.mimeType,
|
|
266
|
+
iconColor,
|
|
267
|
+
recolorRefused,
|
|
268
|
+
),
|
|
226
269
|
size: imageSizeFromBuffer(probe),
|
|
227
270
|
};
|
|
228
271
|
} catch (err) {
|
|
@@ -237,7 +280,7 @@ async function streamOneResult({
|
|
|
237
280
|
// the rest.
|
|
238
281
|
export async function downloadOneResult({
|
|
239
282
|
client,
|
|
240
|
-
presId,
|
|
283
|
+
presId = null,
|
|
241
284
|
backendURL = null,
|
|
242
285
|
searchId,
|
|
243
286
|
assetsDir,
|
|
@@ -259,6 +302,16 @@ export async function downloadOneResult({
|
|
|
259
302
|
});
|
|
260
303
|
if (streamed) return streamed;
|
|
261
304
|
|
|
305
|
+
// The capped base64 fallback below is a PANE route. On the pane-free path
|
|
306
|
+
// there is no window to relay through — and an add-in that serves the direct
|
|
307
|
+
// routes always serves the direct stream — so a missing stream there is a real
|
|
308
|
+
// failure, not a reason to reach for a window that may not exist.
|
|
309
|
+
if (!presId) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"the add-in's engine routes returned no bytes for that result — re-run the search, then pick again",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
262
315
|
// Fallback for an add-in that predates the streaming route: bytes ride the
|
|
263
316
|
// pane's Socket.IO ack, capped by its 10 MB frame limit.
|
|
264
317
|
const res = await client.engineDownload({
|