pindeck 3.0.0-beta.2 → 3.1.0-beta.1
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/README.md +94 -16
- package/front.mjs +318 -0
- package/lib/deck-list.mjs +49 -0
- package/lib/deck-target.mjs +25 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +26 -8
- package/lib/login.mjs +61 -0
- package/lib/usage.mjs +15 -0
- package/package.json +4 -3
- package/public/bridge.js +248 -69
- package/public/editor.css +24 -3
- package/public/editor.js +78 -21
- package/public/front.css +14 -0
- package/public/index.html +9 -1
- package/server.mjs +116 -101
package/server.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import path from "node:path";
|
|
|
24
24
|
import crypto from "node:crypto";
|
|
25
25
|
import { spawn, execFile } from "node:child_process";
|
|
26
26
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
-
import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
27
|
+
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
28
28
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
29
29
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
30
30
|
import { CodexSession } from "./lib/codex.mjs";
|
|
@@ -39,18 +39,14 @@ import { isSupportedPlatform, detectCapabilities, publicCapabilities, capability
|
|
|
39
39
|
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
40
40
|
import { readInstructions } from "./lib/instructions.mjs";
|
|
41
41
|
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
42
|
+
import { deckTarget, homeDir } from "./lib/deck-target.mjs";
|
|
43
|
+
import { goodHost, LOGIN_PAGE, createLogin } from "./lib/login.mjs";
|
|
44
|
+
import { USAGE } from "./lib/usage.mjs";
|
|
42
45
|
|
|
43
46
|
/* ---- Config ------------------------------------------------------------------ */
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
|
|
48
|
-
folder), which then opens its index.html.
|
|
49
|
-
login prints a new one-time login link for the PinDeck running for that deck
|
|
50
|
-
(without a deck: for the PinDeck started last).
|
|
51
|
-
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
52
|
-
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
53
|
-
"assetRoots" in ~/.pindeck-v3/config.json.`;
|
|
47
|
+
/* Started by the front (pindeck, front.mjs) for one of the decks it serves: the front logs browsers in,
|
|
48
|
+
prints the links and owns ~/.pindeck-v3/server.json; this server tells it its address over IPC. */
|
|
49
|
+
const FRONT = !!process.env.PINDECK_FRONT && !!process.send;
|
|
54
50
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
55
51
|
const argv = process.argv.slice(2);
|
|
56
52
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
@@ -69,23 +65,14 @@ const LOGIN = argv[0] === "login";
|
|
|
69
65
|
if (LOGIN) argv.shift();
|
|
70
66
|
const PROVIDER = opt("--provider", "");
|
|
71
67
|
const MODEL = opt("--model", "");
|
|
72
|
-
/* The deck: a file (talk.html), or a folder, which means its index.html
|
|
73
|
-
|
|
74
|
-
const TARGET = path.resolve(argv[0] || process.cwd());
|
|
75
|
-
const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
|
|
76
|
-
const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
|
|
77
|
-
const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
|
|
78
|
-
const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
|
|
79
|
-
const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
|
|
68
|
+
/* The deck: a file (talk.html), or a folder, which means its index.html (see lib/deck-target.mjs) */
|
|
69
|
+
const { dir: DECK_DIR, file: DECK_FILE, named: NAMED, name: DECK_NAME, key: deckKey } = deckTarget(argv[0] || process.cwd());
|
|
80
70
|
const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
|
|
81
71
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
82
|
-
const HOME_DIR =
|
|
83
|
-
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
84
|
-
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
85
|
-
const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
86
|
-
crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
|
|
72
|
+
const HOME_DIR = homeDir();
|
|
87
73
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
88
74
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
75
|
+
const SHOT_NAME = /^[\w-][\w.-]*\.png$/; // a file name in SHOT_DIR, as the editor may ask for it
|
|
89
76
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
90
77
|
/* pindeck login: a new one-time login link from the running server, asked with the token in its server
|
|
91
78
|
file (readable only by this user) */
|
|
@@ -126,15 +113,18 @@ if (deckProblem) {
|
|
|
126
113
|
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
127
114
|
process.exit(1);
|
|
128
115
|
}
|
|
129
|
-
//
|
|
130
|
-
const
|
|
116
|
+
// reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
|
|
117
|
+
const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
|
|
118
|
+
const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
|
|
131
119
|
if (frameworkProblem) {
|
|
132
120
|
console.error(frameworkProblem);
|
|
133
121
|
process.exit(1);
|
|
134
122
|
}
|
|
135
123
|
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
136
124
|
port is bound (see Start), so a failed start leaves no state behind. */
|
|
137
|
-
|
|
125
|
+
// the second: the CLI's fallback. Behind the front none: its own server.json leads pindeck login to the front
|
|
126
|
+
// (a deck server's port is no use to a browser behind a tunnel), and the assistant's CLI gets this server's address.
|
|
127
|
+
const SERVER_FILES = FRONT ? [] : [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")];
|
|
138
128
|
function writeServerInfo() {
|
|
139
129
|
for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
140
130
|
const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
|
|
@@ -159,6 +149,22 @@ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8",
|
|
|
159
149
|
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
|
|
160
150
|
const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
|
|
161
151
|
const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
|
|
152
|
+
/* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
|
|
153
|
+
their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
|
|
154
|
+
the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
|
|
155
|
+
screenshot is the slide itself. */
|
|
156
|
+
function deckSlides() {
|
|
157
|
+
const tree = parse(readDeck(DECK_FILE) || "");
|
|
158
|
+
const selector = slideSelector(tree);
|
|
159
|
+
return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
|
|
160
|
+
}
|
|
161
|
+
/* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
|
|
162
|
+
start-up check's message, which the editor shows and Claude is told */
|
|
163
|
+
function deckProblemNow() {
|
|
164
|
+
const html = readDeck(DECK_FILE);
|
|
165
|
+
return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
|
|
166
|
+
}
|
|
167
|
+
let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
|
|
162
168
|
const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
|
|
163
169
|
function writeDeck(f, content, manual = false) {
|
|
164
170
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
@@ -283,15 +289,25 @@ const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (
|
|
|
283
289
|
/* Headless Chrome on macOS often lingers long after it has written its output, so we
|
|
284
290
|
watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
|
|
285
291
|
let chromeSeq = 0;
|
|
292
|
+
/* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
|
|
293
|
+
a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
|
|
294
|
+
to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
|
|
295
|
+
function sized(url, size) {
|
|
296
|
+
const u = new URL(url);
|
|
297
|
+
u.searchParams.set("width", size.width);
|
|
298
|
+
u.searchParams.set("height", size.height);
|
|
299
|
+
return u.href;
|
|
300
|
+
}
|
|
286
301
|
function chrome(args, done) {
|
|
287
302
|
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
288
303
|
return new Promise((resolve, reject) => {
|
|
289
304
|
// a profile per run: concurrent runs must not share one
|
|
290
305
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
306
|
+
const { size } = deckSlides();
|
|
291
307
|
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
|
|
292
308
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
293
|
-
|
|
294
|
-
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, a) : a)]);
|
|
309
|
+
`--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
310
|
+
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
|
|
295
311
|
let out = "", err = "", finished = false;
|
|
296
312
|
const finish = (fn, v) => {
|
|
297
313
|
if (finished) return;
|
|
@@ -372,7 +388,7 @@ function exportPdf(kind, includeHidden = false) {
|
|
|
372
388
|
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
373
389
|
finally { snapshots.delete(id); }
|
|
374
390
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
375
|
-
url:
|
|
391
|
+
url: `deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
376
392
|
};
|
|
377
393
|
const job = exportQueue.then(run, run);
|
|
378
394
|
exportQueue = job.catch(() => {});
|
|
@@ -467,7 +483,8 @@ let catalog = { claude: { models: [] }, codex: { models: [] } }, discovering = n
|
|
|
467
483
|
let session = null, starting = null;
|
|
468
484
|
const status = () => ({ busy: state.busy, switching: state.switching, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
469
485
|
running: !!session?.running, provider: settings.provider, models: catalog, deck: DECK_DIR, deckFile: DECK_FILE,
|
|
470
|
-
chrome: CAPS.screenshot.available,
|
|
486
|
+
chrome: CAPS.screenshot.available, slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
487
|
+
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
471
488
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
472
489
|
const pushStatus = () => broadcast("status", status());
|
|
473
490
|
|
|
@@ -479,6 +496,15 @@ const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md
|
|
|
479
496
|
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
480
497
|
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
481
498
|
`other decks may use them too.\n` : "");
|
|
499
|
+
/* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
|
|
500
|
+
function slidesInstructions() {
|
|
501
|
+
const { selector } = deckSlides();
|
|
502
|
+
return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
|
|
503
|
+
`CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
|
|
504
|
+
`instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
|
|
505
|
+
`a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
|
|
506
|
+
`left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
|
|
507
|
+
}
|
|
482
508
|
/* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
|
|
483
509
|
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
484
510
|
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
@@ -515,7 +541,7 @@ When context mentions Read, use pindeck_read. Read screenshots with pindeck_read
|
|
|
515
541
|
Read README.md and the deck instructions below for conventions. Use layout after every visual change.
|
|
516
542
|
PDF tables should be reconstructed as HTML; pdf-figure renders figures. Audio is embedded by path.
|
|
517
543
|
|
|
518
|
-
## Hand edits`) + deckInstructions();
|
|
544
|
+
## Hand edits`) + slidesInstructions() + deckInstructions();
|
|
519
545
|
function makeSession(choice) {
|
|
520
546
|
let initial = null;
|
|
521
547
|
const options = {
|
|
@@ -535,7 +561,7 @@ function makeSession(choice) {
|
|
|
535
561
|
explicit), PinDeck's own commands, never the locked paths. Deny rules win over allow rules. */
|
|
536
562
|
const agent = choice.provider === "codex" ? new CodexSession({ ...options, settings: choice,
|
|
537
563
|
instructions: codexInstructions(), tools: agentTools }) : new ClaudeSession({ ...options,
|
|
538
|
-
args: () => ["--append-system-prompt", systemPrompt + deckInstructions(),
|
|
564
|
+
args: () => ["--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
539
565
|
"--setting-sources", "", "--strict-mcp-config", "--safe-mode", "--disable-slash-commands", // no settings files, no MCP servers or connectors
|
|
540
566
|
"--permission-mode", "default", "--tools", "Read,Glob,Grep,Edit,Write,Bash",
|
|
541
567
|
"--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)",
|
|
@@ -628,11 +654,13 @@ function toolSummary(name, input = {}) {
|
|
|
628
654
|
const r = path.relative(DECK_DIR, p);
|
|
629
655
|
return !r || r.startsWith("..") ? path.basename(p) : r;
|
|
630
656
|
};
|
|
657
|
+
// a picture in the screenshots folder: the chat shows it
|
|
658
|
+
const shotOf = (p) => (p && realOr(path.dirname(path.resolve(p))) === realOr(SHOT_DIR) && SHOT_NAME.test(path.basename(p)) ? { shot: path.basename(p) } : {});
|
|
631
659
|
switch (name) {
|
|
632
|
-
case "pindeck_read": return { name: "Read", detail: base(input.path) };
|
|
660
|
+
case "pindeck_read": return { name: "Read", detail: base(input.path), ...shotOf(input.path) };
|
|
633
661
|
case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
|
|
634
662
|
case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
|
|
635
|
-
case "Read": return { name: "Read", detail: base(input.file_path) };
|
|
663
|
+
case "Read": return { name: "Read", detail: base(input.file_path), ...shotOf(input.file_path) };
|
|
636
664
|
case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
|
|
637
665
|
case "Bash": {
|
|
638
666
|
// a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
|
|
@@ -867,6 +895,14 @@ function scheduleFlush() {
|
|
|
867
895
|
};
|
|
868
896
|
eidTimer = setTimeout(pass, 800);
|
|
869
897
|
}
|
|
898
|
+
if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
|
|
899
|
+
const now = { selector: deckSlides().selector, problem: deckProblemNow() };
|
|
900
|
+
if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
|
|
901
|
+
if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
|
|
902
|
+
deckNow = now;
|
|
903
|
+
pushStatus();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
870
906
|
// manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
|
|
871
907
|
broadcast("files", { files, manual, rev: deckRev() });
|
|
872
908
|
}, 120);
|
|
@@ -1115,57 +1151,9 @@ async function refineImport(imp, pdf, plans, eids, first) {
|
|
|
1115
1151
|
|
|
1116
1152
|
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
1117
1153
|
const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
|
|
1118
|
-
/*
|
|
1119
|
-
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1122
|
-
/* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
|
|
1123
|
-
where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
|
|
1124
|
-
code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
|
|
1125
|
-
and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
|
|
1126
|
-
carries this server's port. */
|
|
1127
|
-
const loginCodes = new Map(); // code -> expiry (ms)
|
|
1128
|
-
function loginCode(ttl) {
|
|
1129
|
-
const now = Date.now();
|
|
1130
|
-
for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
|
|
1131
|
-
const code = crypto.randomBytes(16).toString("hex");
|
|
1132
|
-
loginCodes.set(code, now + ttl);
|
|
1133
|
-
return code;
|
|
1134
|
-
}
|
|
1135
|
-
const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
|
|
1136
|
-
const cookieName = () => `pindeck-${PORT}`;
|
|
1137
|
-
const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
|
|
1138
|
-
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
|
|
1139
|
-
function cookie(req, name) {
|
|
1140
|
-
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
1141
|
-
const i = part.indexOf("=");
|
|
1142
|
-
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
1143
|
-
}
|
|
1144
|
-
return null;
|
|
1145
|
-
}
|
|
1146
|
-
const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
|
|
1147
|
-
const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
1148
|
-
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
1149
|
-
<h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
|
|
1150
|
-
for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
|
|
1151
|
-
PinDeck checkout).</p>`;
|
|
1152
|
-
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
1153
|
-
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
1154
|
-
function login(res, url) {
|
|
1155
|
-
const code = url.searchParams.get("code") || "";
|
|
1156
|
-
const exp = loginCodes.get(code);
|
|
1157
|
-
loginCodes.delete(code);
|
|
1158
|
-
if (!exp || exp < Date.now()) {
|
|
1159
|
-
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1160
|
-
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
1161
|
-
}
|
|
1162
|
-
let next = url.searchParams.get("next") || "/";
|
|
1163
|
-
if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
|
|
1164
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
1165
|
-
"set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
|
|
1166
|
-
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
1167
|
-
res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
|
|
1168
|
-
}
|
|
1154
|
+
/* Who may read the editor, the deck and the API: see lib/login.mjs. Behind the front, browsers log in
|
|
1155
|
+
there, and the front sends this server's token in a header. */
|
|
1156
|
+
const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
|
|
1169
1157
|
/* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
|
|
1170
1158
|
its command line: the page sends it to a one-time login code, which leads on to the target. */
|
|
1171
1159
|
function chromeStart(profile, target) {
|
|
@@ -1239,9 +1227,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1239
1227
|
return res.end(html);
|
|
1240
1228
|
}
|
|
1241
1229
|
// /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
|
|
1242
|
-
// /files/, so that ../ paths reach the root: /deck/ alone redirects there
|
|
1230
|
+
// /files/, so that ../ paths reach the root: /deck/ alone redirects there (a relative redirect, which
|
|
1231
|
+
// stays under the front's /d/<deck>/).
|
|
1243
1232
|
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1244
|
-
res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1233
|
+
res.writeHead(302, { location: ".." + DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1245
1234
|
return res.end();
|
|
1246
1235
|
}
|
|
1247
1236
|
const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
|
|
@@ -1254,11 +1243,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
1254
1243
|
return serveFile(res, f, (s) => {
|
|
1255
1244
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
1256
1245
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
1257
|
-
if (mode === "pdf")
|
|
1246
|
+
if (mode === "pdf") {
|
|
1247
|
+
s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
|
|
1248
|
+
// a deck that names its slides (in the file as it is now) is laid out by the bridge; reveal.js lays out its own
|
|
1249
|
+
return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
|
|
1250
|
+
}
|
|
1258
1251
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
1259
1252
|
});
|
|
1260
1253
|
}
|
|
1261
1254
|
|
|
1255
|
+
// /shots/: a screenshot for the chat, a PNG directly in the screenshots folder (no subfolder, no symlink out)
|
|
1256
|
+
if (req.method === "GET" && p.startsWith("/shots/")) {
|
|
1257
|
+
if (!ok) return text(res, 403, "Not logged in");
|
|
1258
|
+
const name = decodeURIComponent(p.slice(7));
|
|
1259
|
+
if (!SHOT_NAME.test(name)) return text(res, 403, "Forbidden");
|
|
1260
|
+
const f = path.join(SHOT_DIR, name);
|
|
1261
|
+
let real;
|
|
1262
|
+
try { real = fs.realpathSync(f); } catch { return text(res, 404, "Not found"); }
|
|
1263
|
+
return path.dirname(real) === realOr(SHOT_DIR) ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1262
1266
|
// API: token required (header or login cookie); POSTs must come from the editor page itself
|
|
1263
1267
|
if (!p.startsWith("/api/")) return text(res, 404, "Not found");
|
|
1264
1268
|
if (!ok) return text(res, 403, "Bad token");
|
|
@@ -1315,7 +1319,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1315
1319
|
// only files PinDeck itself stored can be attached
|
|
1316
1320
|
b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
|
|
1317
1321
|
b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
|
|
1318
|
-
const entry = addEntry({ kind: "user", text: b.text,
|
|
1322
|
+
const entry = addEntry({ kind: "user", text: b.text,
|
|
1323
|
+
screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
|
|
1319
1324
|
attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
|
|
1320
1325
|
selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
|
|
1321
1326
|
pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
|
|
@@ -1348,7 +1353,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1348
1353
|
const rel = attachPath(original);
|
|
1349
1354
|
writeDeckFile(rel, buf);
|
|
1350
1355
|
state.uploads.add(rel);
|
|
1351
|
-
return json(res, 200, { path: rel, kind, bytes: buf.length, url:
|
|
1356
|
+
return json(res, 200, { path: rel, kind, bytes: buf.length, url: `deck/${rel}` });
|
|
1352
1357
|
}
|
|
1353
1358
|
if (p === "/api/upload-remove" && req.method === "POST") {
|
|
1354
1359
|
const b = await body(req);
|
|
@@ -1371,14 +1376,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1371
1376
|
const b = await body(req);
|
|
1372
1377
|
if (staleEdit(res, b)) return;
|
|
1373
1378
|
const before = snapshot(EDITABLE);
|
|
1374
|
-
const
|
|
1379
|
+
const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
|
|
1375
1380
|
const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
|
|
1376
1381
|
if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
|
|
1377
1382
|
const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
|
|
1378
1383
|
if (!r.changed) return json(res, 200, { unchanged: true });
|
|
1384
|
+
const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
|
|
1385
|
+
: "skipped when presenting and in PDF exports";
|
|
1379
1386
|
const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
|
|
1380
1387
|
`The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
|
|
1381
|
-
`data-visibility="hidden": they stay in the file but are skipped
|
|
1388
|
+
`data-visibility="hidden": they stay in the file but are ${skipped}.`,
|
|
1382
1389
|
{ manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
|
|
1383
1390
|
return json(res, 200, { turnId, index });
|
|
1384
1391
|
}
|
|
@@ -1624,11 +1631,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1624
1631
|
return text(res, r.status || 200, r.text);
|
|
1625
1632
|
}
|
|
1626
1633
|
if (p === "/api/slides") {
|
|
1627
|
-
|
|
1634
|
+
const list = slideList(), why = !list.length && deckProblemNow();
|
|
1635
|
+
if (why) return text(res, 409, why);
|
|
1636
|
+
return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
|
|
1628
1637
|
}
|
|
1629
1638
|
if (p === "/api/layout" || p === "/api/shot") {
|
|
1630
1639
|
const slide = resolveSlide(url.searchParams.get("slide"));
|
|
1631
|
-
if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1640
|
+
if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1632
1641
|
if (!slide.eid) runEidPass();
|
|
1633
1642
|
const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
|
|
1634
1643
|
if (p === "/api/shot") {
|
|
@@ -1673,12 +1682,18 @@ server.listen(PORT, "127.0.0.1", () => {
|
|
|
1673
1682
|
process.exit(1);
|
|
1674
1683
|
}
|
|
1675
1684
|
ready = true;
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1685
|
+
if (FRONT) {
|
|
1686
|
+
// the front prints the login link and the capabilities; it ends this server when it goes, even killed
|
|
1687
|
+
process.send({ ready: { url: ORIGIN, token: TOKEN } });
|
|
1688
|
+
process.on("disconnect", () => { if (!quitting) quit(); }); // a second quit() would exit at once
|
|
1689
|
+
} else {
|
|
1690
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
1691
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
1692
|
+
console.log(`Deck: ${DECK_DIR}`);
|
|
1693
|
+
console.log(`Server: ${CAPS.label}`);
|
|
1694
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
1695
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
1696
|
+
}
|
|
1682
1697
|
discoverModels().then(() => {
|
|
1683
1698
|
if (!settings.model) {
|
|
1684
1699
|
const models = catalog[settings.provider].models;
|