pindeck 2.8.2 → 2.10.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/README.md +211 -23
- package/cli.mjs +6 -3
- package/lib/capabilities.mjs +118 -0
- package/lib/claude.mjs +8 -3
- package/lib/deck-files.mjs +94 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +59 -0
- package/lib/instructions.mjs +74 -0
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -3
- package/public/bridge.js +248 -69
- package/public/editor.css +1 -0
- package/public/editor.js +82 -24
- package/public/index.html +2 -2
- package/server.mjs +630 -155
package/server.mjs
CHANGED
|
@@ -12,17 +12,19 @@
|
|
|
12
12
|
- stores figures and audio attached to chat messages in <deck>/figures/ for Claude to use, and attached
|
|
13
13
|
PDFs (papers, reports, figures) in ~/.pindeck, outside the deck
|
|
14
14
|
- imports slides from PowerPoint files in the deck's own layouts
|
|
15
|
-
Listens on 127.0.0.1 only; every API call
|
|
15
|
+
Listens on 127.0.0.1 only; the editor, the deck and every API call need the per-run token (a login
|
|
16
|
+
cookie in the browser, a header for the CLI).
|
|
16
17
|
Runtime files (chat, screenshots, backups) live in ~/.pindeck, never in the deck or the package. */
|
|
17
18
|
|
|
18
19
|
import http from "node:http";
|
|
20
|
+
import { EventEmitter } from "node:events";
|
|
19
21
|
import fs from "node:fs";
|
|
20
22
|
import os from "node:os";
|
|
21
23
|
import path from "node:path";
|
|
22
24
|
import crypto from "node:crypto";
|
|
23
25
|
import { spawn, execFile } from "node:child_process";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
25
|
-
import { assignEids, parse, findSlides, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
27
|
+
import { assignEids, parse, findSlides, slideSelector, deckSize, setStyles, getInner, setInner, insertAfter, removeElement, deleteTarget, fileRefs, insertSlide, moveSlide, getNotes, setNotes, setAttr, elementEnd } from "./lib/eids.mjs";
|
|
26
28
|
import { unifiedDiff } from "./lib/diff.mjs";
|
|
27
29
|
import { ClaudeSession } from "./lib/claude.mjs";
|
|
28
30
|
import { formatLayout, formatPin } from "./lib/layout.mjs";
|
|
@@ -30,62 +32,194 @@ import { printLayout, printPdf, PDF_HEAD_TAG, PDF_PAGE_TAG } from "./lib/pdf.mjs
|
|
|
30
32
|
import { readPptx } from "./lib/pptx.mjs";
|
|
31
33
|
import { exportPdf as pptxToPdf, renderJobs, renderAvailable, pdfInfo } from "./lib/pptx-render.mjs";
|
|
32
34
|
import { buildImport, planSlides } from "./lib/pptx-import.mjs";
|
|
35
|
+
import { isSupportedPlatform, detectCapabilities, publicCapabilities, capabilityPrompt, parsePort, cliCommand, findExecutable } from "./lib/capabilities.mjs";
|
|
36
|
+
import { unsupportedMessage } from "./lib/frameworks.mjs";
|
|
37
|
+
import { readInstructions } from "./lib/instructions.mjs";
|
|
38
|
+
import { writeAtomic, sameContent, revision, scanFiles, scanChanges } from "./lib/deck-files.mjs";
|
|
33
39
|
|
|
34
40
|
/* ---- Config ------------------------------------------------------------------ */
|
|
35
|
-
const USAGE = `Usage: pindeck [deck-folder] [--port 5180] [--model <name>]
|
|
41
|
+
const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--model <name>] [--asset-root <folder>]...
|
|
42
|
+
pindeck login [deck-folder | deck-file.html]
|
|
36
43
|
|
|
37
|
-
Opens
|
|
38
|
-
|
|
44
|
+
Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
|
|
45
|
+
folder), which then opens its index.html. A deck made otherwise names its slides in its <head> with
|
|
46
|
+
<meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
|
|
47
|
+
login prints a new one-time login link for the PinDeck running for that deck
|
|
48
|
+
(without a deck: for the PinDeck started last).
|
|
49
|
+
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
50
|
+
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
51
|
+
"assetRoots" in ~/.pindeck/config.json.`;
|
|
39
52
|
const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
40
53
|
const argv = process.argv.slice(2);
|
|
41
54
|
if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
42
55
|
const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
|
|
43
|
-
const
|
|
44
|
-
|
|
56
|
+
const portArg = opt("--port", process.env.PORT || 5180);
|
|
57
|
+
const assetArgs = [];
|
|
58
|
+
for (let a; (a = opt("--asset-root")) !== undefined;) assetArgs.push(a);
|
|
59
|
+
let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
|
|
60
|
+
if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
|
|
61
|
+
if (!isSupportedPlatform(process.platform)) {
|
|
45
62
|
const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
|
|
46
63
|
await showPlatformNotice(PORT);
|
|
47
64
|
process.exit(0);
|
|
48
65
|
}
|
|
66
|
+
const LOGIN = argv[0] === "login";
|
|
67
|
+
if (LOGIN) argv.shift();
|
|
49
68
|
const MODEL = opt("--model", process.env.CLAUDE_MODEL || "");
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
69
|
+
/* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
|
|
70
|
+
that is not a folder is a file, so a missing file gets a clear message. */
|
|
71
|
+
const TARGET = path.resolve(argv[0] || process.cwd());
|
|
72
|
+
const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
|
|
73
|
+
const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
|
|
74
|
+
const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
|
|
75
|
+
const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
|
|
76
|
+
const DECK_NAME = NAMED ? DECK_FILE.replace(/\.html?$/i, "") : path.basename(DECK_DIR); // for exports and titles
|
|
77
|
+
const EDITABLE = [DECK_FILE, "theme.css"]; // Claude may edit these
|
|
53
78
|
const LOCKED = ["brand.css"]; // never, enforced twice
|
|
54
79
|
const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck");
|
|
55
|
-
|
|
56
|
-
|
|
80
|
+
/* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
|
|
81
|
+
histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
|
|
82
|
+
const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
|
|
83
|
+
crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
|
|
57
84
|
const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
|
|
58
85
|
const SHOT_DIR = path.join(STATE_DIR, "shots");
|
|
59
86
|
const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
|
|
87
|
+
/* pindeck login: a new one-time login link from the running server, asked with the token in its server
|
|
88
|
+
file (readable only by this user) */
|
|
89
|
+
if (LOGIN) {
|
|
90
|
+
const files = [path.join(STATE_DIR, "server.json"), ...(argv[0] ? [] : [path.join(HOME_DIR, "server.json")])];
|
|
91
|
+
let info = null;
|
|
92
|
+
for (const f of files) { try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {} }
|
|
93
|
+
if (!info) { console.error(`No PinDeck is running${argv[0] ? ` for ${DECK_DIR}` : ""}.`); process.exit(1); }
|
|
94
|
+
try {
|
|
95
|
+
const res = await fetch(`${info.url}/api/login-link`, { headers: { "x-editor-token": info.token } });
|
|
96
|
+
if (!res.ok) throw new Error(res.status);
|
|
97
|
+
console.log(`PinDeck (${info.deckDir}): ${(await res.json()).url}`);
|
|
98
|
+
process.exit(0);
|
|
99
|
+
} catch {
|
|
100
|
+
console.error(`The PinDeck for ${info.deckDir} at ${info.url} does not answer. Is it still running?`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
60
104
|
const TOKEN = crypto.randomBytes(16).toString("hex");
|
|
61
|
-
|
|
105
|
+
let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
|
|
62
106
|
const CLI_REL = path.relative(DECK_DIR, path.join(EDITOR_DIR, "cli.mjs")).split(path.sep).join("/");
|
|
63
|
-
const CLI_CMD =
|
|
107
|
+
const CLI_CMD = cliCommand(CLI_REL);
|
|
108
|
+
const CAPS = detectCapabilities();
|
|
64
109
|
|
|
65
|
-
|
|
66
|
-
|
|
110
|
+
/* Checks before anything is written: a deck that cannot be opened or saved is left exactly as it is. */
|
|
111
|
+
function checkDeck() {
|
|
112
|
+
const file = path.join(DECK_DIR, DECK_FILE);
|
|
113
|
+
if (!fs.existsSync(file)) return `No ${DECK_FILE} in ${DECK_DIR}`;
|
|
114
|
+
try { fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK); }
|
|
115
|
+
catch { return `${file} cannot be read and written (check its permissions).`; }
|
|
116
|
+
// saving replaces the file through a temporary file next to it, so the folder must be writable too
|
|
117
|
+
try { fs.accessSync(DECK_DIR, fs.constants.W_OK); }
|
|
118
|
+
catch { return `${DECK_DIR} is not writable: PinDeck saves the deck through a temporary file in that folder.`; }
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const deckProblem = checkDeck();
|
|
122
|
+
if (deckProblem) {
|
|
123
|
+
console.error(`${deckProblem}\n\n${USAGE}`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
// reveal.js, or a deck that names its slides: another framework's deck is left exactly as it is (no ids, no state)
|
|
127
|
+
const deckAtStart = fs.readFileSync(path.join(DECK_DIR, DECK_FILE), "utf8");
|
|
128
|
+
const frameworkProblem = unsupportedMessage(deckAtStart, DECK_FILE);
|
|
129
|
+
if (frameworkProblem) {
|
|
130
|
+
console.error(frameworkProblem);
|
|
67
131
|
process.exit(1);
|
|
68
132
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
133
|
+
/* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
|
|
134
|
+
port is bound (see Start), so a failed start leaves no state behind. */
|
|
135
|
+
const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
|
|
136
|
+
function writeServerInfo() {
|
|
137
|
+
for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
138
|
+
const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
|
|
139
|
+
for (const f of SERVER_FILES) {
|
|
140
|
+
fs.writeFileSync(f, info, { mode: 0o600 });
|
|
141
|
+
fs.chmodSync(f, 0o600);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/* On exit: only our own server files, never those of a PinDeck started since for this or another deck. */
|
|
145
|
+
function removeServerInfo() {
|
|
146
|
+
for (const f of SERVER_FILES) {
|
|
147
|
+
try { if (JSON.parse(fs.readFileSync(f, "utf8")).token === TOKEN) fs.rmSync(f, { force: true }); } catch {}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
73
150
|
|
|
74
151
|
/* ---- Small helpers --------------------------------------------------------------- */
|
|
75
152
|
const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
|
|
76
153
|
mjs: "text/javascript; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png",
|
|
77
154
|
jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon",
|
|
78
155
|
woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", wav: "audio/wav", mp3: "audio/mpeg",
|
|
79
|
-
|
|
156
|
+
ogg: "audio/ogg", oga: "audio/ogg", opus: "audio/ogg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac",
|
|
157
|
+
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf", md: "text/plain; charset=utf-8" };
|
|
80
158
|
const hash = (s) => crypto.createHash("sha1").update(s).digest("hex");
|
|
81
159
|
const readDeck = (f) => { try { return fs.readFileSync(path.join(DECK_DIR, f), "utf8"); } catch { return null; } };
|
|
160
|
+
/* A deck made without reveal.js names its slides with <meta name="pindeck:slides"> (null for reveal.js) and
|
|
161
|
+
their size with <meta name="pindeck:size">. Read from the deck as it is now, so a changed tag counts from
|
|
162
|
+
the next headless run or Claude session. Headless Chrome opens such a deck at the slide size, so a
|
|
163
|
+
screenshot is the slide itself. */
|
|
164
|
+
function deckSlides() {
|
|
165
|
+
const tree = parse(readDeck(DECK_FILE) || "");
|
|
166
|
+
const selector = slideSelector(tree);
|
|
167
|
+
return { selector, size: (selector && deckSize(tree)) || { width: 1280, height: 720 } };
|
|
168
|
+
}
|
|
169
|
+
/* Why the deck no longer names slides PinDeck can use (a tag changed while PinDeck runs), or null: the
|
|
170
|
+
start-up check's message, which the editor shows and Claude is told */
|
|
171
|
+
function deckProblemNow() {
|
|
172
|
+
const html = readDeck(DECK_FILE);
|
|
173
|
+
return (html != null && unsupportedMessage(html, DECK_FILE)?.replace(/ Nothing was changed\.$/, "")) || null;
|
|
174
|
+
}
|
|
175
|
+
let deckNow = { selector: deckSlides().selector, problem: null }; // for the editor; kept current by scheduleFlush
|
|
82
176
|
const ourWrites = new Map(); // file -> { hash, manual } of what we wrote, to tell our writes from others
|
|
83
177
|
function writeDeck(f, content, manual = false) {
|
|
84
178
|
ourWrites.set(f, { hash: hash(content), manual });
|
|
85
|
-
|
|
179
|
+
writeAtomic(path.join(DECK_DIR, f), content);
|
|
180
|
+
}
|
|
181
|
+
/* Other files PinDeck writes into the deck (uploads, imported pictures, undo/redo of deleted files) */
|
|
182
|
+
function writeDeckFile(rel, data, manual = true) {
|
|
183
|
+
const abs = path.join(DECK_DIR, rel);
|
|
184
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
185
|
+
ourWrites.set(rel, { hash: hash(data.toString("utf8")), manual }); // manual: no slide reload for a new file
|
|
186
|
+
writeAtomic(abs, data);
|
|
86
187
|
}
|
|
87
188
|
const snapshot = (files) => Object.fromEntries(files.map((f) => [f, readDeck(f)]));
|
|
88
|
-
|
|
189
|
+
/* A change (a Claude turn or a hand edit) keeps each file it touched as it was before and after: text as
|
|
190
|
+
a string, or as a Buffer from a scan of the whole deck (null: the file did not exist). */
|
|
191
|
+
const changeFiles = (t) => [...new Set([...Object.keys(t.before), ...Object.keys(t.after)])];
|
|
192
|
+
const isText = (v) => v == null || typeof v === "string" || !v.includes(0);
|
|
193
|
+
const asText = (v) => (v == null ? "" : String(v));
|
|
194
|
+
const isAs = (rel, v) => sameContent(path.join(DECK_DIR, rel), v ?? null);
|
|
195
|
+
function putDeckFile(rel, v) { // undo and redo: back to v (null: remove the file)
|
|
196
|
+
if (v == null) return fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
197
|
+
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
198
|
+
writeDeck(rel, v);
|
|
199
|
+
}
|
|
200
|
+
function fileChange(rel, before, after) {
|
|
201
|
+
if (!isText(before) || !isText(after)) return { file: rel, added: 0, removed: 0, binary: true };
|
|
202
|
+
const d = unifiedDiff(asText(before), asText(after), rel);
|
|
203
|
+
return { file: rel, added: d.added, removed: d.removed };
|
|
204
|
+
}
|
|
205
|
+
const fileDiff = (rel, before, after) => (!isText(before) || !isText(after)
|
|
206
|
+
? `Binary file ${rel} ${before == null ? "added" : after == null ? "deleted" : "changed"}`
|
|
207
|
+
: unifiedDiff(asText(before), asText(after), rel).text);
|
|
208
|
+
/* The revision of the editable files: sent with every API answer, checked on the editor's changes */
|
|
209
|
+
const deckRev = () => revision(DECK_DIR, EDITABLE);
|
|
210
|
+
const json = (res, code, obj) => {
|
|
211
|
+
res.writeHead(code, { "content-type": "application/json", "x-deck-rev": deckRev() });
|
|
212
|
+
res.end(JSON.stringify(obj));
|
|
213
|
+
};
|
|
214
|
+
/* A change from the editor made on files that have changed since it loaded them: refused (409), nothing
|
|
215
|
+
written. The editor keeps the user's text and reloads. Requests without a revision (older tabs, the
|
|
216
|
+
notes sent while the tab closes) are not checked. */
|
|
217
|
+
function staleEdit(res, b) {
|
|
218
|
+
if (b.rev == null || b.rev === deckRev()) return false;
|
|
219
|
+
json(res, 409, { code: "stale", rev: deckRev(),
|
|
220
|
+
error: "The deck changed outside PinDeck since this slide was shown, so your change was not saved. The slide has been reloaded." });
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
89
223
|
const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8" }); res.end(s); };
|
|
90
224
|
async function body(req) {
|
|
91
225
|
let s = "";
|
|
@@ -109,12 +243,13 @@ setInterval(() => { for (const res of clients) res.write(": ping\n\n"); }, 20000
|
|
|
109
243
|
let backedUp = false;
|
|
110
244
|
function runEidPass() {
|
|
111
245
|
const html = readDeck(DECK_FILE);
|
|
246
|
+
if (html == null) return { changed: false, assigned: [] }; // being replaced by another program: next time
|
|
112
247
|
const r = assignEids(html);
|
|
113
248
|
if (r.changed) {
|
|
114
249
|
if (!backedUp) {
|
|
115
250
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
116
|
-
fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true });
|
|
117
|
-
fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html);
|
|
251
|
+
fs.mkdirSync(path.join(STATE_DIR, "backups"), { recursive: true, mode: 0o700 });
|
|
252
|
+
fs.writeFileSync(path.join(STATE_DIR, "backups", `index-${ts}.html`), html, { mode: 0o600 });
|
|
118
253
|
backedUp = true;
|
|
119
254
|
}
|
|
120
255
|
writeDeck(DECK_FILE, r.html);
|
|
@@ -131,11 +266,12 @@ function runEidPass() {
|
|
|
131
266
|
let gitFilter = null; // null: not checked yet
|
|
132
267
|
const git = (args, cb) => execFile("git", ["-C", DECK_DIR, ...args], { encoding: "utf8" }, (err, out) => cb(err, (out || "").trim()));
|
|
133
268
|
function refreshGit() {
|
|
134
|
-
|
|
269
|
+
// another git command (an IDE's `git status`) may hold the index lock: try again a few times, checking afresh
|
|
270
|
+
const run = (tries = 3) => git(["hash-object", `--path=${DECK_FILE}`, DECK_FILE], (e1, cleaned) => {
|
|
135
271
|
if (e1 || !cleaned) return;
|
|
136
272
|
git(["ls-files", "-s", "--", DECK_FILE], (e2, entry) => {
|
|
137
273
|
const staged = entry.split(/\s+/)[1];
|
|
138
|
-
if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], () => {});
|
|
274
|
+
if (!e2 && staged === cleaned) git(["add", "--", DECK_FILE], (e3) => { if (e3 && tries > 1) setTimeout(() => run(tries - 1), 300); });
|
|
139
275
|
});
|
|
140
276
|
});
|
|
141
277
|
if (gitFilter === null) {
|
|
@@ -157,23 +293,29 @@ function resolveSlide(spec) {
|
|
|
157
293
|
}
|
|
158
294
|
|
|
159
295
|
/* ---- Headless Chrome (screenshots, and layout when no editor tab is open) --------------- */
|
|
160
|
-
const CHROME =
|
|
161
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
162
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
163
|
-
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
164
|
-
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
165
|
-
"/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && fs.existsSync(p));
|
|
296
|
+
const CHROME = CAPS.chromePath; // null when Chrome features are unavailable (see lib/capabilities.mjs)
|
|
166
297
|
/* Headless Chrome on macOS often lingers long after it has written its output, so we
|
|
167
298
|
watch the output and stop Chrome as soon as `done(stdout, stderr)` says we have it. */
|
|
168
299
|
let chromeSeq = 0;
|
|
300
|
+
/* The page gets the window's size too. Chrome lays it out about 87 px shorter than the window (room for
|
|
301
|
+
a toolbar it does not draw) and grows it only for the screenshot itself, too late for some versions
|
|
302
|
+
to lay the slide out again, so shot and dump lay the deck out for this size (see bridge.js). */
|
|
303
|
+
function sized(url, size) {
|
|
304
|
+
const u = new URL(url);
|
|
305
|
+
u.searchParams.set("width", size.width);
|
|
306
|
+
u.searchParams.set("height", size.height);
|
|
307
|
+
return u.href;
|
|
308
|
+
}
|
|
169
309
|
function chrome(args, done) {
|
|
170
|
-
if (!CHROME) return Promise.reject(new Error(
|
|
310
|
+
if (!CHROME) return Promise.reject(new Error(`Screenshots are not available: ${CAPS.screenshot.reason}.`));
|
|
171
311
|
return new Promise((resolve, reject) => {
|
|
172
312
|
// a profile per run: concurrent runs must not share one
|
|
173
313
|
const profile = path.join(STATE_DIR, "chrome", `p${process.pid}-${++chromeSeq}`);
|
|
314
|
+
const { size } = deckSlides();
|
|
174
315
|
const proc = spawn(CHROME, ["--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
|
|
175
316
|
"--no-default-browser-check", "--disable-extensions", "--mute-audio", `--user-data-dir=${profile}`,
|
|
176
|
-
|
|
317
|
+
`--window-size=${size.width},${size.height}`, "--force-device-scale-factor=1", "--virtual-time-budget=5000",
|
|
318
|
+
...args.map((a) => a.startsWith(ORIGIN + "/") ? chromeStart(profile, sized(a, size)) : a)]);
|
|
177
319
|
let out = "", err = "", finished = false;
|
|
178
320
|
const finish = (fn, v) => {
|
|
179
321
|
if (finished) return;
|
|
@@ -187,12 +329,13 @@ function chrome(args, done) {
|
|
|
187
329
|
proc.stdout.on("data", (d) => { out += d; check(); });
|
|
188
330
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
189
331
|
proc.on("error", (e) => finish(reject, e));
|
|
190
|
-
|
|
332
|
+
// "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
|
|
333
|
+
proc.on("close", () => (done(out, err) ? finish(resolve, out) : finish(reject, new Error("Chrome exited without output."))));
|
|
191
334
|
const timer = setTimeout(() => finish(reject, new Error("Chrome timed out.")), 25000);
|
|
192
335
|
});
|
|
193
336
|
}
|
|
194
337
|
const deckUrl = (mode, params = {}) =>
|
|
195
|
-
`${ORIGIN}
|
|
338
|
+
`${ORIGIN}${DECK_PAGE}?` + new URLSearchParams({ editor: mode, showHiddenSlides: "true", ...params });
|
|
196
339
|
async function takeShot(slide) {
|
|
197
340
|
const pins = state.pins.filter((p) => p.slide.eid === slide.eid).map((p) => ({ n: p.n, eid: p.eid, at: p.at, point: p.point }));
|
|
198
341
|
const out = path.join(SHOT_DIR, `${slide.eid.replace(/[^\w-]/g, "_")}-${Date.now()}.png`);
|
|
@@ -226,26 +369,31 @@ function measureInEditor(slide) {
|
|
|
226
369
|
pending.set(id, { resolve, reject, t });
|
|
227
370
|
});
|
|
228
371
|
}
|
|
229
|
-
const measure = (slide) => measureInEditor(slide).catch(() =>
|
|
372
|
+
const measure = (slide) => measureInEditor(slide).catch((e) => {
|
|
373
|
+
if (CAPS.headlessLayout.available) return measureHeadless(slide);
|
|
374
|
+
throw Object.assign(new Error(`Layout needs the editor open in a browser (${e.message}); ` +
|
|
375
|
+
`headless layout is not available: ${CAPS.headlessLayout.reason}.`), { status: 503 });
|
|
376
|
+
});
|
|
230
377
|
|
|
231
378
|
/* PDF export. handout: one page per slide, build steps in their final state.
|
|
232
379
|
presentation: one page per build step (a slide with two clicks gives three pages).
|
|
233
380
|
Files go to <deck>/export/. One export at a time. */
|
|
234
381
|
let exportQueue = Promise.resolve(), snapSeq = 0;
|
|
235
|
-
const snapshots = new Map(); // id -> static print layout, served
|
|
382
|
+
const snapshots = new Map(); // id -> static print layout, served next to the deck page as __pindeck-print-<id>.html
|
|
236
383
|
function exportPdf(kind, includeHidden = false) {
|
|
237
|
-
if (!
|
|
384
|
+
if (!CAPS.pdfExport.available) return Promise.reject(Object.assign(new Error(`PDF export is not available: ${CAPS.pdfExport.reason}.`), { status: 501 }));
|
|
238
385
|
const split = kind === "presentation";
|
|
239
|
-
const name = `${
|
|
386
|
+
const name = `${DECK_NAME}-${split ? "presentation" : "handout"}.pdf`;
|
|
240
387
|
const out = path.join(DECK_DIR, "export", name);
|
|
241
388
|
const profile = (step) => path.join(STATE_DIR, "chrome", `pdf-${process.pid}-${Date.now()}-${step}`);
|
|
242
389
|
const run = async () => {
|
|
243
390
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
244
|
-
const
|
|
245
|
-
|
|
391
|
+
const layoutProfile = profile("layout"), printProfile = profile("print");
|
|
392
|
+
const html = await printLayout(CHROME, chromeStart(layoutProfile,
|
|
393
|
+
`${ORIGIN}${DECK_PAGE}?editor=pdf&print-pdf&pdfSeparateFragments=${split}&showHiddenSlides=${!!includeHidden}`), layoutProfile);
|
|
246
394
|
const id = `${process.pid}-${++snapSeq}`;
|
|
247
395
|
snapshots.set(id, html);
|
|
248
|
-
try { await printPdf(CHROME, `${ORIGIN}
|
|
396
|
+
try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
|
|
249
397
|
finally { snapshots.delete(id); }
|
|
250
398
|
return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
|
|
251
399
|
url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
|
|
@@ -267,8 +415,15 @@ try {
|
|
|
267
415
|
const saved = JSON.parse(fs.readFileSync(CHAT_FILE, "utf8"));
|
|
268
416
|
Object.assign(state, { chat: saved.chat || [], seq: saved.seq || 0, sessionId: saved.sessionId || null });
|
|
269
417
|
} catch {}
|
|
270
|
-
|
|
271
|
-
|
|
418
|
+
/* Saved shortly after the last change, in one atomic write: overlapping writes could leave a broken file. */
|
|
419
|
+
let chatTimer = null;
|
|
420
|
+
function writeChat() {
|
|
421
|
+
clearTimeout(chatTimer);
|
|
422
|
+
chatTimer = null;
|
|
423
|
+
try { writeAtomic(CHAT_FILE, JSON.stringify({ chat: state.chat.slice(-400), seq: state.seq, sessionId: state.sessionId })); }
|
|
424
|
+
catch (e) { console.error(e.message); }
|
|
425
|
+
}
|
|
426
|
+
const saveChat = () => { chatTimer ??= setTimeout(writeChat, 100); };
|
|
272
427
|
function addEntry(entry) {
|
|
273
428
|
entry.id = ++state.seq;
|
|
274
429
|
entry.time = Date.now();
|
|
@@ -294,35 +449,99 @@ if (MODELS.includes(fileConfig.model)) settings.model = fileConfig.model;
|
|
|
294
449
|
else if (fileConfig.model !== undefined) console.warn(`config.json: unknown model "${fileConfig.model}" (use ${MODELS.join(" or ")}); using ${DEFAULTS.model}.`);
|
|
295
450
|
if (EFFORTS.includes(fileConfig.effort)) settings.effort = fileConfig.effort;
|
|
296
451
|
else if (fileConfig.effort !== undefined) console.warn(`config.json: unknown effort "${fileConfig.effort}" (use ${EFFORTS.join(", ")}); using ${DEFAULTS.effort}.`);
|
|
452
|
+
/* Asset roots (--asset-root, and "assetRoots" in config.json): folders outside the deck whose files the
|
|
453
|
+
deck may use. The outermost one that contains the deck is served at /files/, with the deck page inside
|
|
454
|
+
it, so a relative path such as ../shared/style.css reaches its file. Every file served, from the deck
|
|
455
|
+
or from /files/, must really lie (after following symlinks) inside the deck folder or an asset root:
|
|
456
|
+
a symlink that leads anywhere else is refused. */
|
|
457
|
+
const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
458
|
+
const ASSET_ROOTS = [...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])].map((r) => {
|
|
459
|
+
const abs = path.resolve(expandHome(String(r)));
|
|
460
|
+
let st = null;
|
|
461
|
+
try { st = fs.statSync(abs); } catch {}
|
|
462
|
+
if (!st?.isDirectory()) { console.error(`Asset root ${abs} is not a folder.\n\n${USAGE}`); process.exit(2); }
|
|
463
|
+
return abs;
|
|
464
|
+
});
|
|
465
|
+
const within = (dir, p) => p === dir || p.startsWith(dir + path.sep);
|
|
466
|
+
const realOr = (p) => { try { return fs.realpathSync(p); } catch { return p; } };
|
|
467
|
+
const SERVE_ROOT = ASSET_ROOTS.filter((r) => within(r, DECK_DIR)).sort((a, b) => a.length - b.length)[0] || null;
|
|
468
|
+
const ALLOWED_REAL = [DECK_DIR, ...ASSET_ROOTS].map(realOr);
|
|
469
|
+
const encPath = (rel) => rel.split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
|
|
470
|
+
/* The deck page's URL, and the folder URL its relative paths resolve against */
|
|
471
|
+
const DECK_PAGE = SERVE_ROOT ? `/files/${[encPath(path.relative(SERVE_ROOT, DECK_DIR)), encodeURIComponent(DECK_FILE)].filter(Boolean).join("/")}`
|
|
472
|
+
: `/deck/${encodeURIComponent(DECK_FILE)}`;
|
|
473
|
+
const DECK_BASE = DECK_PAGE.slice(0, DECK_PAGE.lastIndexOf("/") + 1);
|
|
474
|
+
/* A file to serve: inside its base by path, and inside the deck or an asset root once symlinks are
|
|
475
|
+
followed. null: refuse. A missing file passes, and is answered 404. */
|
|
476
|
+
function servable(base, rel) {
|
|
477
|
+
const f = safeJoin(base, rel);
|
|
478
|
+
if (!f) return null;
|
|
479
|
+
let real;
|
|
480
|
+
try { real = fs.realpathSync(f); } catch { return f; }
|
|
481
|
+
return ALLOWED_REAL.some((d) => within(d, real)) ? f : null;
|
|
482
|
+
}
|
|
297
483
|
const saveSettings = () => fs.writeFile(CONFIG_FILE, JSON.stringify({ ...fileConfig, model: settings.model, effort: settings.effort }, null, 2) + "\n", () => {});
|
|
298
|
-
|
|
484
|
+
const completeSettings = () => { // create or complete the file (at start, once the port is bound)
|
|
485
|
+
if (fileConfig.model !== settings.model || fileConfig.effort !== settings.effort) saveSettings();
|
|
486
|
+
};
|
|
299
487
|
if (MODEL) settings.model = MODEL; // --model on the command line: this run only, not saved
|
|
300
488
|
|
|
301
489
|
const status = () => ({ busy: state.busy, model: state.model, sessionId: state.sessionId, settings: { ...settings },
|
|
302
|
-
running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome:
|
|
490
|
+
running: session.running, deck: DECK_DIR, deckFile: DECK_FILE, chrome: CAPS.screenshot.available,
|
|
491
|
+
slideSelector: deckNow.selector, deckProblem: deckNow.problem,
|
|
492
|
+
capabilities: publicCapabilities(CAPS), rev: deckRev(),
|
|
303
493
|
undo: changeLabel(liveTurns().at(-1)), redo: changeLabel(state.redo.at(-1)) });
|
|
304
494
|
const pushStatus = () => broadcast("status", status());
|
|
305
495
|
|
|
306
496
|
const systemPrompt = fs.readFileSync(path.join(EDITOR_DIR, "lib/system-prompt.md"), "utf8")
|
|
307
|
-
.replaceAll("{{CLI}}", CLI_CMD)
|
|
497
|
+
.replaceAll("{{CLI}}", CLI_CMD).replaceAll("{{DECK}}", DECK_FILE) + capabilityPrompt(CAPS, CLI_CMD) +
|
|
498
|
+
// a template's own notes usually say index.html; other decks may share this folder
|
|
499
|
+
(NAMED ? `\n\n## This deck's file\nThe slides are in ${DECK_FILE}. Where the deck's notes below say index.html, they mean ` +
|
|
500
|
+
`${DECK_FILE}. Other .html files in this folder are other decks: do not edit them.\n` : "") +
|
|
501
|
+
(ASSET_ROOTS.length ? `\n\n## Files outside the deck folder\nThe deck may use files in ${ASSET_ROOTS.join(", ")}: refer to ` +
|
|
502
|
+
`them with paths relative to ${DECK_FILE} (such as ../shared/style.css). You can read them; do not edit them, ` +
|
|
503
|
+
`other decks may use them too.\n` : "");
|
|
504
|
+
/* For a deck made without reveal.js: what its slides are, from its selector as it is at each session start */
|
|
505
|
+
function slidesInstructions() {
|
|
506
|
+
const { selector } = deckSlides();
|
|
507
|
+
return selector ? `\n\n## Slides of this deck\nThis deck is not made with reveal.js: its slides are the elements matching the ` +
|
|
508
|
+
`CSS selector \`${selector}\` (<meta name="pindeck:slides">), and the deck's own script shows them. Where these ` +
|
|
509
|
+
`instructions say <section> or reveal.js, read an element matching that selector, and that script. A new slide is ` +
|
|
510
|
+
`a new such element next to the others; keep the pindeck meta tags. Hidden slides (data-visibility="hidden") are ` +
|
|
511
|
+
`left out of PinDeck's PDF exports; whether presenting skips them depends on the deck's script.\n` : "";
|
|
512
|
+
}
|
|
513
|
+
/* What Claude may never change, whatever the edit rules allow: the locked brand files, vendored libraries,
|
|
514
|
+
git's own files, and other decks in this folder (a named deck shares its folder with them). Deny rules
|
|
515
|
+
win over allow rules; a locked file changed anyway is restored after the turn (lock 2 in finishTurn). */
|
|
516
|
+
function lockedPaths() {
|
|
517
|
+
let others = [];
|
|
518
|
+
try { others = fs.readdirSync(DECK_DIR).filter((f) => /\.html?$/i.test(f) && f !== DECK_FILE); } catch {}
|
|
519
|
+
return [...LOCKED, "vendor/**", ".git/**", ...others];
|
|
520
|
+
}
|
|
308
521
|
/* Claude Code loads no settings files (--setting-sources ""): an allow rule in the user's, the deck's
|
|
309
522
|
or a parent repo's settings would let Claude run more than PinDeck grants (and the user's hooks and
|
|
310
|
-
MCP servers have no place in a slide editor). That also skips
|
|
311
|
-
the system prompt instead, read afresh at each session start.
|
|
523
|
+
MCP servers have no place in a slide editor). That also skips CLAUDE.md files, so the deck's own go
|
|
524
|
+
into the system prompt instead, read afresh at each session start: CLAUDE.md and AGENTS.md (the name
|
|
525
|
+
other coding agents read), with their @path imports (lib/instructions.mjs). Parent folders' files are
|
|
526
|
+
not loaded; a deck imports shared rules explicitly. Decks copied from the example call the CLI as
|
|
527
|
+
`node ../cli.mjs`, which is right only next to PinDeck: that becomes this server's command. */
|
|
312
528
|
function deckInstructions() {
|
|
313
|
-
|
|
314
|
-
|
|
529
|
+
return ["CLAUDE.md", "AGENTS.md"].map((name) => {
|
|
530
|
+
const s = readInstructions(path.join(DECK_DIR, name));
|
|
531
|
+
return s ? `\n\n## The deck's ${name}\n${s.replace(/node \.\.\/cli\.mjs\b/g, () => CLI_CMD)}\n` : "";
|
|
532
|
+
}).join("");
|
|
315
533
|
}
|
|
316
534
|
let stderrTail = "";
|
|
317
535
|
const session = new ClaudeSession({
|
|
318
536
|
cwd: DECK_DIR,
|
|
319
|
-
env: { DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN },
|
|
537
|
+
env: () => ({ DECK_EDITOR_URL: ORIGIN, DECK_EDITOR_TOKEN: TOKEN }),
|
|
320
538
|
args: () => [
|
|
321
|
-
"--append-system-prompt", systemPrompt + deckInstructions(),
|
|
539
|
+
"--append-system-prompt", systemPrompt + slidesInstructions() + deckInstructions(),
|
|
322
540
|
"--setting-sources", "", "--strict-mcp-config", // no settings files, no MCP servers or connectors
|
|
323
541
|
"--permission-mode", "default",
|
|
324
|
-
|
|
325
|
-
"--
|
|
542
|
+
// any file in the deck folder (Edit rules cover Write too; Write is named as well, to be explicit)
|
|
543
|
+
"--allowedTools", "Read", "Glob", "Grep", "Edit(./**)", "Write(./**)", `Bash(${CLI_CMD}:*)`,
|
|
544
|
+
"--disallowedTools", ...lockedPaths().flatMap((f) => [`Edit(./${f})`, `Write(./${f})`]),
|
|
326
545
|
...(settings.model ? ["--model", settings.model] : []),
|
|
327
546
|
...(settings.effort ? ["--effort", settings.effort] : []),
|
|
328
547
|
],
|
|
@@ -338,7 +557,10 @@ const session = new ClaudeSession({
|
|
|
338
557
|
},
|
|
339
558
|
});
|
|
340
559
|
function ensureSession() {
|
|
341
|
-
if (
|
|
560
|
+
if (session.running || quitting || !detectCapabilities().claude.available) return;
|
|
561
|
+
stderrTail = "";
|
|
562
|
+
session.start(state.sessionId);
|
|
563
|
+
pushStatus();
|
|
342
564
|
}
|
|
343
565
|
|
|
344
566
|
function toolSummary(name, input = {}) {
|
|
@@ -394,10 +616,13 @@ function handleEvent(ev) {
|
|
|
394
616
|
}
|
|
395
617
|
|
|
396
618
|
function startTurn(promptText, userEntry) {
|
|
397
|
-
|
|
619
|
+
// every deck file as it is now: Claude may change any of them (not brand.css or vendor/), and undo
|
|
620
|
+
// takes back all it changed
|
|
621
|
+
state.turn = { id: userEntry.id, before: snapshot([...EDITABLE, ...LOCKED]), scan: scanFiles(DECK_DIR) };
|
|
398
622
|
state.busy = true;
|
|
399
623
|
pushStatus();
|
|
400
624
|
ensureSession();
|
|
625
|
+
if (!session.running) return finishTurn({ error: detectCapabilities().claude.reason || "Claude Code could not be started." });
|
|
401
626
|
session.send([{ type: "text", text: promptText }]);
|
|
402
627
|
}
|
|
403
628
|
|
|
@@ -417,11 +642,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
417
642
|
}
|
|
418
643
|
toolEntries.clear();
|
|
419
644
|
const eid = runEidPass();
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
645
|
+
const { changed, tooLarge } = scanChanges(turn.scan, scanFiles(DECK_DIR));
|
|
646
|
+
const before = {}, after = {};
|
|
647
|
+
for (const [rel, c] of Object.entries(changed)) { before[rel] = c.before; after[rel] = c.after; }
|
|
648
|
+
// the deck file and theme.css first, then the rest by name
|
|
649
|
+
const order = (rel) => (EDITABLE.includes(rel) ? EDITABLE.indexOf(rel) : EDITABLE.length);
|
|
650
|
+
const files = Object.keys(changed).sort((a, b) => order(a) - order(b) || a.localeCompare(b))
|
|
651
|
+
.map((rel) => fileChange(rel, before[rel], after[rel]));
|
|
425
652
|
// after a hand-off: imported pictures that Claude replaced (nothing refers to them any more) are
|
|
426
653
|
// deleted; undo brings them back with the turn
|
|
427
654
|
const deletedFiles = [];
|
|
@@ -431,11 +658,13 @@ function finishTurn({ error, cost, duration, denials = [] }) {
|
|
|
431
658
|
deletedFiles.push({ rel, data: fs.readFileSync(abs) });
|
|
432
659
|
fs.rmSync(abs, { force: true });
|
|
433
660
|
}
|
|
434
|
-
const t = { id: turn.id, kind: "claude", before
|
|
661
|
+
const t = { id: turn.id, kind: "claude", before, after, files, deletedFiles, undone: false };
|
|
435
662
|
if (files.length) { state.turns.push(t); state.redo = []; }
|
|
436
663
|
addEntry({ kind: "turn", turnId: files.length ? t.id : null, files, error, cost, duration,
|
|
437
664
|
denials, restored, newIds: eid.assigned.length });
|
|
438
665
|
if (deletedFiles.length) addEntry({ kind: "notice", text: `Removed ${deletedFiles.map((f) => f.rel).join(", ")}: replaced, and no longer used.` });
|
|
666
|
+
if (tooLarge.length) addEntry({ kind: "notice", text: `${tooLarge.join(", ")} changed in this turn but ${tooLarge.length > 1 ? "are" : "is"} ` +
|
|
667
|
+
`too large to keep a copy of (over 2 MB): Undo leaves ${tooLarge.length > 1 ? "them" : "it"} as ${tooLarge.length > 1 ? "they are" : "it is"}.` });
|
|
439
668
|
pushStatus();
|
|
440
669
|
}
|
|
441
670
|
|
|
@@ -494,7 +723,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
494
723
|
for (const L of layouts) ctx.push("Layout:\n" + formatLayout(L));
|
|
495
724
|
if (screenshot) ctx.push(`Screenshot of slide ${screenshot.slide} with the pin markers: ${screenshot.path} (Read it).`);
|
|
496
725
|
if (attachments.length) ctx.push("Attached files (the user added them to this message; they are saved in the deck folder, " +
|
|
497
|
-
|
|
726
|
+
`use these paths relative to ${DECK_FILE}):\n` + attachments.map((a) => formatAttachment(a) + formatDrop(a)).join("\n"));
|
|
498
727
|
return (ctx.length ? `<editor-context>\n${ctx.join("\n\n")}\n</editor-context>\n\n` : "") + userText;
|
|
499
728
|
}
|
|
500
729
|
|
|
@@ -503,7 +732,7 @@ function buildPrompt({ text: userText, pins = [], selection, layouts = [], curre
|
|
|
503
732
|
screenshots folder, for Claude to look at before choosing; with one, into the deck. */
|
|
504
733
|
async function pdfFigure({ file = "", page = "1", box = "", out = "", width = "" }) {
|
|
505
734
|
const bad = (t, status = 400) => ({ status, text: t });
|
|
506
|
-
if (
|
|
735
|
+
if (!CAPS.pdfPages.available) return bad(CAPS.pdfPages.reason, 501);
|
|
507
736
|
const abs = [path.isAbsolute(file) ? file : path.join(DECK_DIR, file), path.join(PDF_DIR, path.basename(file))]
|
|
508
737
|
.map((f) => path.resolve(f)).find((f) => /\.pdf$/i.test(f) && fs.existsSync(f) &&
|
|
509
738
|
[DECK_DIR, PDF_DIR].some((d) => f.startsWith(d + path.sep)));
|
|
@@ -521,23 +750,30 @@ async function pdfFigure({ file = "", page = "1", box = "", out = "", width = ""
|
|
|
521
750
|
if (out && fs.existsSync(path.join(DECK_DIR, out)) && !state.pdfFigures.has(out)) return bad(`${out} exists already; choose another name.`, 409);
|
|
522
751
|
const tmp = path.join(SHOT_DIR, `pdf-${slug(path.basename(abs, ".pdf"))}-p${n}-${Date.now()}.png`);
|
|
523
752
|
await renderJobs(abs, [{ page: n, out: tmp, box: pts, scale }], STATE_DIR, { slideWidth: pw });
|
|
524
|
-
if (!fs.existsSync(tmp)) return bad("
|
|
753
|
+
if (!fs.existsSync(tmp)) return bad("The PDF page could not be rendered.", 500);
|
|
525
754
|
const size = `${Math.round(pts[2] * scale)} × ${Math.round(pts[3] * scale)} px`;
|
|
526
755
|
if (!out) return { text: `${tmp} (${size}). Read it; add a figures/ name to save it into the deck.` };
|
|
527
756
|
const buf = fs.readFileSync(tmp);
|
|
528
757
|
fs.rmSync(tmp, { force: true });
|
|
529
|
-
|
|
530
|
-
ourWrites.set(out, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
|
|
531
|
-
fs.writeFileSync(path.join(DECK_DIR, out), buf);
|
|
758
|
+
writeDeckFile(out, buf);
|
|
532
759
|
state.pdfFigures.add(out);
|
|
533
760
|
return { text: `${out} (${size})` };
|
|
534
761
|
}
|
|
535
762
|
|
|
536
|
-
/* ---- File watching --------------------------------------------------------------------------
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
763
|
+
/* ---- File watching --------------------------------------------------------------------------
|
|
764
|
+
Changes are collected for 120 ms and sent to the editor with the deck's revision, so a tab that missed
|
|
765
|
+
events (reconnect) can still tell that it shows an old state. Dot files (PinDeck's temporary files,
|
|
766
|
+
editors' swap files), backups and export/ are ignored. If the watcher fails (folder replaced, too many
|
|
767
|
+
files), it is started again and the editor told to reload everything. */
|
|
768
|
+
let changed = new Set(), flush = null, eidTimer = null, watcher = null;
|
|
769
|
+
function onFileEvent(_ev, file) {
|
|
770
|
+
if (!file) { changed.add(DECK_FILE); return scheduleFlush(); } // some systems omit the name: assume the deck
|
|
771
|
+
file = String(file).split(path.sep).join("/");
|
|
772
|
+
if (/(^|\/)\.|\.bak$|~$|^export\//.test(file)) return;
|
|
773
|
+
changed.add(file);
|
|
774
|
+
scheduleFlush();
|
|
775
|
+
}
|
|
776
|
+
function scheduleFlush() {
|
|
541
777
|
clearTimeout(flush);
|
|
542
778
|
flush = setTimeout(() => {
|
|
543
779
|
const files = [...changed];
|
|
@@ -553,14 +789,79 @@ fs.watch(DECK_DIR, { recursive: true }, (_ev, file) => {
|
|
|
553
789
|
if (external) {
|
|
554
790
|
state.notes = [...new Set([...state.notes,
|
|
555
791
|
"The deck files changed outside this chat since your last turn (edited by hand or by another tool). Re-read before editing."])];
|
|
556
|
-
// new elements from a hand edit get ids once the file is quiet
|
|
792
|
+
// new elements from a hand edit get ids once the file is quiet: unchanged for 800 ms and present
|
|
557
793
|
clearTimeout(eidTimer);
|
|
558
|
-
|
|
794
|
+
const seen = hash(readDeck(DECK_FILE) ?? "");
|
|
795
|
+
const pass = () => {
|
|
796
|
+
if (state.busy) return;
|
|
797
|
+
const now = readDeck(DECK_FILE);
|
|
798
|
+
if (now == null || hash(now) !== seen) return; // still being written; its own event comes
|
|
799
|
+
runEidPass();
|
|
800
|
+
};
|
|
801
|
+
eidTimer = setTimeout(pass, 800);
|
|
802
|
+
}
|
|
803
|
+
if (files.includes(DECK_FILE)) { // the slide tags, by hand or by Claude: tell both when they stop working
|
|
804
|
+
const now = { selector: deckSlides().selector, problem: deckProblemNow() };
|
|
805
|
+
if (now.selector !== deckNow.selector || now.problem !== deckNow.problem) {
|
|
806
|
+
if (now.problem) state.notes = [...new Set([...state.notes, `PinDeck cannot use the deck's slides now: ${now.problem}`])];
|
|
807
|
+
deckNow = now;
|
|
808
|
+
pushStatus();
|
|
809
|
+
}
|
|
559
810
|
}
|
|
560
811
|
// manual: only drag/resize writes, which the browser already shows; no reload of the visible slide
|
|
561
|
-
broadcast("files", { files, manual });
|
|
812
|
+
broadcast("files", { files, manual, rev: deckRev() });
|
|
562
813
|
}, 120);
|
|
563
|
-
}
|
|
814
|
+
}
|
|
815
|
+
/* Linux: Node's recursive watch follows files by inode there, and loses a file once it is replaced by a
|
|
816
|
+
rename, the way most editors and PinDeck itself save: later changes to index.html went unseen. Watching
|
|
817
|
+
every folder (inotify on directories) keeps working; folders created later are added as they appear.
|
|
818
|
+
Hidden folders (.git) and node_modules are skipped. */
|
|
819
|
+
function watchFolders(root, onEvent) {
|
|
820
|
+
const watchers = new Map(), tree = new EventEmitter();
|
|
821
|
+
const add = (dir) => {
|
|
822
|
+
if (watchers.has(dir)) return;
|
|
823
|
+
let w;
|
|
824
|
+
try {
|
|
825
|
+
w = fs.watch(dir, (ev, name) => {
|
|
826
|
+
if (name == null) return onEvent(ev, null);
|
|
827
|
+
const abs = path.join(dir, String(name));
|
|
828
|
+
if (ev === "rename") { try { if (fs.statSync(abs).isDirectory()) scan(abs); } catch {} }
|
|
829
|
+
onEvent(ev, path.relative(root, abs));
|
|
830
|
+
});
|
|
831
|
+
} catch (e) {
|
|
832
|
+
if (dir === root) throw e;
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
w.on("error", (e) => {
|
|
836
|
+
try { w.close(); } catch {}
|
|
837
|
+
watchers.delete(dir);
|
|
838
|
+
if (dir === root) tree.emit("error", e); // a removed subfolder just drops out
|
|
839
|
+
});
|
|
840
|
+
watchers.set(dir, w);
|
|
841
|
+
};
|
|
842
|
+
const scan = (dir) => {
|
|
843
|
+
add(dir);
|
|
844
|
+
let entries = [];
|
|
845
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch {}
|
|
846
|
+
for (const d of entries) if (d.isDirectory() && !d.name.startsWith(".") && d.name !== "node_modules") scan(path.join(dir, d.name));
|
|
847
|
+
};
|
|
848
|
+
scan(root);
|
|
849
|
+
tree.close = () => { for (const w of watchers.values()) try { w.close(); } catch {} watchers.clear(); };
|
|
850
|
+
return tree;
|
|
851
|
+
}
|
|
852
|
+
function startWatcher() {
|
|
853
|
+
watcher = process.platform === "linux" ? watchFolders(DECK_DIR, onFileEvent)
|
|
854
|
+
: fs.watch(DECK_DIR, { recursive: true }, onFileEvent);
|
|
855
|
+
watcher.on("error", (e) => {
|
|
856
|
+
console.error(`Watching ${DECK_DIR} failed (${e.message}); starting again.`);
|
|
857
|
+
try { watcher.close(); } catch {}
|
|
858
|
+
setTimeout(() => {
|
|
859
|
+
if (quitting) return;
|
|
860
|
+
try { startWatcher(); } catch (e2) { console.error(`Cannot watch ${DECK_DIR}: ${e2.message}`); }
|
|
861
|
+
broadcast("files", { files: [...EDITABLE], manual: false, rev: deckRev(), resync: true });
|
|
862
|
+
}, 1000);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
564
865
|
|
|
565
866
|
/* ---- Hand edits ----------------------------------------------------------------------------------------- */
|
|
566
867
|
function recordHandEdit(before, html, label, note, { manual = true, deletedFiles = [], addedFiles = [], chat = true } = {}) {
|
|
@@ -597,21 +898,42 @@ function deletePlan(eid) {
|
|
|
597
898
|
return { ...target, html: rest, files };
|
|
598
899
|
}
|
|
599
900
|
|
|
600
|
-
/* ---- Undo (Claude's turns and hand edits share one history) ----------------------------------------
|
|
901
|
+
/* ---- Undo (Claude's turns and hand edits share one history) ----------------------------------------
|
|
902
|
+
Undo puts back the files as they were before a change, Redo as they were after it. Both first check that
|
|
903
|
+
every file they touch is exactly as that change left it (or, for Redo, as the undo left it): the editable
|
|
904
|
+
files, and the pictures or audio files they would delete or bring back. Anything else means the files
|
|
905
|
+
were changed since, by hand or by another program, and going back would silently throw that work away.
|
|
906
|
+
Then nothing is written and the answer is 409 with the files that differ. */
|
|
907
|
+
function undoConflicts(t, direction) {
|
|
908
|
+
const expect = direction === "undo" ? t.after : t.before;
|
|
909
|
+
const files = changeFiles(t).filter((rel) => !isAs(rel, expect[rel]));
|
|
910
|
+
// undo brings deleted files back (their place must be free, or hold the same bytes) and removes added
|
|
911
|
+
// ones (which must be unchanged); redo the other way round
|
|
912
|
+
const comeBack = direction === "undo" ? t.deletedFiles || [] : t.addedFiles || [];
|
|
913
|
+
const go = direction === "undo" ? t.addedFiles || [] : t.deletedFiles || [];
|
|
914
|
+
for (const { rel, data } of comeBack) {
|
|
915
|
+
const abs = path.join(DECK_DIR, rel);
|
|
916
|
+
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
917
|
+
}
|
|
918
|
+
for (const { rel, data } of go) {
|
|
919
|
+
const abs = path.join(DECK_DIR, rel);
|
|
920
|
+
if (fs.existsSync(abs) && !sameContent(abs, data)) files.push(rel);
|
|
921
|
+
}
|
|
922
|
+
return files;
|
|
923
|
+
}
|
|
924
|
+
function conflict(res, direction, files) {
|
|
925
|
+
json(res, 409, { code: "diverged", files,
|
|
926
|
+
error: `${files.join(", ")} changed since then (edited outside PinDeck?), so ${direction === "undo" ? "Undo" : "Redo"} was not done: ` +
|
|
927
|
+
`it would overwrite those changes. Reload to see the files as they are.` });
|
|
928
|
+
}
|
|
601
929
|
function undo(res) {
|
|
602
930
|
if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
|
|
603
931
|
const t = liveTurns().at(-1);
|
|
604
932
|
if (!t) return json(res, 409, { error: "Nothing to undo." });
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
else fs.rmSync(path.join(DECK_DIR, f), { force: true }); // the change created the file
|
|
610
|
-
}
|
|
611
|
-
for (const { rel, data } of t.deletedFiles || []) { // files deleted with an element come back too
|
|
612
|
-
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
613
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), data);
|
|
614
|
-
}
|
|
933
|
+
const diverged = undoConflicts(t, "undo");
|
|
934
|
+
if (diverged.length) return conflict(res, "undo", diverged);
|
|
935
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.before[rel])) putDeckFile(rel, t.before[rel] ?? null); // null: the change created it
|
|
936
|
+
for (const { rel, data } of t.deletedFiles || []) writeDeckFile(rel, data, false); // files deleted with an element come back too
|
|
615
937
|
for (const { rel } of t.addedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true }); // imported figures go
|
|
616
938
|
t.undone = true;
|
|
617
939
|
state.redo.push(t);
|
|
@@ -619,7 +941,6 @@ function undo(res) {
|
|
|
619
941
|
? `The user undid their hand edit (${t.label}); ${DECK_FILE} is back to the state before it.`
|
|
620
942
|
: `The user undid your change from turn ${t.id}; ${t.files.map((f) => f.file).join(" and ")} are back to the state before it. Re-read before editing.`);
|
|
621
943
|
broadcast("undone", { turnId: t.id, undone: true });
|
|
622
|
-
if (edited) addEntry({ kind: "notice", text: "Undone. Edits made after that change were reverted too." });
|
|
623
944
|
pushStatus();
|
|
624
945
|
return json(res, 200, {});
|
|
625
946
|
}
|
|
@@ -631,7 +952,6 @@ function undo(res) {
|
|
|
631
952
|
const IMPORT_DIR = path.join(STATE_DIR, "imports");
|
|
632
953
|
const IMPORT_MAX = 500 * 1024 * 1024;
|
|
633
954
|
let lastImport = null; // { id, name, dir, model, render: Promise<pdf path | null>, renderError }
|
|
634
|
-
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
635
955
|
|
|
636
956
|
function startImport(name, buf) {
|
|
637
957
|
const model = readPptx(buf); // first: a file that cannot be read leaves the previous import as it was
|
|
@@ -678,12 +998,7 @@ async function applyImport(imp, numbers, afterEid, refine = false) {
|
|
|
678
998
|
const ls = html0.lastIndexOf("\n", after.start - 1) + 1;
|
|
679
999
|
const indent = /^\s*$/.test(html0.slice(ls, after.start)) ? html0.slice(ls, after.start) : "";
|
|
680
1000
|
const html = assignEids(html0.slice(0, end) + r.fragments.map((f) => `\n\n${indent}` + f).join("") + html0.slice(end)).html;
|
|
681
|
-
for (const f of r.files)
|
|
682
|
-
const abs = path.join(DECK_DIR, f.rel);
|
|
683
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
684
|
-
ourWrites.set(f.rel, { hash: hash(f.data.toString("utf8")), manual: true }); // no reload for each new file
|
|
685
|
-
fs.writeFileSync(abs, f.data);
|
|
686
|
-
}
|
|
1001
|
+
for (const f of r.files) writeDeckFile(f.rel, f.data); // manual: no reload for each new file
|
|
687
1002
|
const all = findSlides(parse(html));
|
|
688
1003
|
const first = afterIdx + 1, last = afterIdx + r.fragments.length;
|
|
689
1004
|
const eids = all.slice(first, last + 1).map((s) => s.attrs["data-eid"]?.value);
|
|
@@ -741,68 +1056,172 @@ async function refineImport(imp, pdf, plans, eids, first) {
|
|
|
741
1056
|
|
|
742
1057
|
/* ---- HTTP ------------------------------------------------------------------------------------- */
|
|
743
1058
|
const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
|
|
744
|
-
|
|
1059
|
+
/* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
|
|
1060
|
+
Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
|
|
1061
|
+
const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
|
|
1062
|
+
|
|
1063
|
+
/* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
|
|
1064
|
+
where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
|
|
1065
|
+
code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
|
|
1066
|
+
and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
|
|
1067
|
+
carries this server's port. */
|
|
1068
|
+
const loginCodes = new Map(); // code -> expiry (ms)
|
|
1069
|
+
function loginCode(ttl) {
|
|
1070
|
+
const now = Date.now();
|
|
1071
|
+
for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
|
|
1072
|
+
const code = crypto.randomBytes(16).toString("hex");
|
|
1073
|
+
loginCodes.set(code, now + ttl);
|
|
1074
|
+
return code;
|
|
1075
|
+
}
|
|
1076
|
+
const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
|
|
1077
|
+
const cookieName = () => `pindeck-${PORT}`;
|
|
1078
|
+
const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
|
|
1079
|
+
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
|
|
1080
|
+
function cookie(req, name) {
|
|
1081
|
+
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
1082
|
+
const i = part.indexOf("=");
|
|
1083
|
+
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
1084
|
+
}
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
|
|
1088
|
+
const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
|
|
1089
|
+
<body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
|
|
1090
|
+
<h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
|
|
1091
|
+
for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
|
|
1092
|
+
PinDeck checkout).</p>`;
|
|
1093
|
+
/* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
|
|
1094
|
+
start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
|
|
1095
|
+
function login(res, url) {
|
|
1096
|
+
const code = url.searchParams.get("code") || "";
|
|
1097
|
+
const exp = loginCodes.get(code);
|
|
1098
|
+
loginCodes.delete(code);
|
|
1099
|
+
if (!exp || exp < Date.now()) {
|
|
1100
|
+
res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1101
|
+
return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
|
|
1102
|
+
}
|
|
1103
|
+
let next = url.searchParams.get("next") || "/";
|
|
1104
|
+
if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
|
|
1105
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
|
|
1106
|
+
"set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
|
|
1107
|
+
const to = JSON.stringify(next).replace(/</g, "\\u003c");
|
|
1108
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
|
|
1109
|
+
}
|
|
1110
|
+
/* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
|
|
1111
|
+
its command line: the page sends it to a one-time login code, which leads on to the target. */
|
|
1112
|
+
function chromeStart(profile, target) {
|
|
1113
|
+
fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
1114
|
+
const start = path.join(profile, "pindeck-start.html");
|
|
1115
|
+
const to = loginUrl(loginCode(120000), target.slice(ORIGIN.length)).replace(/&/g, "&").replace(/"/g, """);
|
|
1116
|
+
fs.writeFileSync(start, `<!doctype html><meta http-equiv="refresh" content="0;url=${to}">`, { mode: 0o600 });
|
|
1117
|
+
return pathToFileURL(start).href;
|
|
1118
|
+
}
|
|
745
1119
|
|
|
746
|
-
|
|
747
|
-
|
|
1120
|
+
/* Files with a length and byte ranges: Safari plays audio and video only from servers that answer a
|
|
1121
|
+
Range request with 206, and seeking needs it everywhere. Transformed (HTML) files are sent whole. */
|
|
1122
|
+
function serveFile(res, file, transform, req) {
|
|
1123
|
+
const type = MIME[path.extname(file).slice(1).toLowerCase()] || "application/octet-stream";
|
|
1124
|
+
if (transform) return fs.readFile(file, (err, data) => {
|
|
748
1125
|
if (err) return text(res, 404, "Not found");
|
|
749
|
-
const
|
|
750
|
-
res.writeHead(200, { "content-type":
|
|
751
|
-
res.end(
|
|
1126
|
+
const out = Buffer.from(transform(String(data)));
|
|
1127
|
+
res.writeHead(200, { "content-type": type, "content-length": out.length, "cache-control": "no-store" });
|
|
1128
|
+
res.end(out);
|
|
1129
|
+
});
|
|
1130
|
+
fs.stat(file, (err, st) => {
|
|
1131
|
+
if (err || !st.isFile()) return text(res, 404, "Not found");
|
|
1132
|
+
const headers = { "content-type": type, "accept-ranges": "bytes", "cache-control": "no-store" };
|
|
1133
|
+
let start = 0, end = st.size - 1, code = 200;
|
|
1134
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req?.headers.range || "").trim()); // one range only
|
|
1135
|
+
if (range && (range[1] || range[2])) {
|
|
1136
|
+
if (range[1]) { start = +range[1]; if (range[2]) end = Math.min(+range[2], end); }
|
|
1137
|
+
else start = Math.max(0, st.size - +range[2]); // suffix: the last n bytes
|
|
1138
|
+
if (start > end || start >= st.size) {
|
|
1139
|
+
res.writeHead(416, { ...headers, "content-range": `bytes */${st.size}` });
|
|
1140
|
+
return res.end();
|
|
1141
|
+
}
|
|
1142
|
+
code = 206;
|
|
1143
|
+
headers["content-range"] = `bytes ${start}-${end}/${st.size}`;
|
|
1144
|
+
}
|
|
1145
|
+
headers["content-length"] = st.size ? end - start + 1 : 0;
|
|
1146
|
+
res.writeHead(code, headers);
|
|
1147
|
+
if (!st.size) return res.end();
|
|
1148
|
+
const stream = fs.createReadStream(file, { start, end });
|
|
1149
|
+
stream.on("error", () => res.destroy());
|
|
1150
|
+
res.on("close", () => stream.destroy());
|
|
1151
|
+
stream.pipe(res);
|
|
752
1152
|
});
|
|
753
1153
|
}
|
|
754
1154
|
|
|
1155
|
+
let ready = false;
|
|
755
1156
|
const server = http.createServer(async (req, res) => {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
if (!
|
|
1157
|
+
if (!ready) return text(res, 503, "PinDeck is starting. Reload in a moment.");
|
|
1158
|
+
const host = String(req.headers.host || "").toLowerCase();
|
|
1159
|
+
if (!goodHost(host)) return text(res, 403, "Bad host");
|
|
759
1160
|
const url = new URL(req.url, ORIGIN);
|
|
760
1161
|
const p = url.pathname;
|
|
761
1162
|
try {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1163
|
+
if (req.method === "GET" && p === "/login") return login(res, url);
|
|
1164
|
+
// UI and deck files (read-only); the editor's own scripts and styles hold nothing secret
|
|
1165
|
+
const ok = authed(req);
|
|
1166
|
+
if (req.method === "GET" && (p === "/" || p === "/index.html")) {
|
|
1167
|
+
if (!ok) { res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); return res.end(LOGIN_PAGE); }
|
|
1168
|
+
return serveFile(res, path.join(EDITOR_DIR, "public/index.html"), null, req);
|
|
1169
|
+
}
|
|
765
1170
|
if (req.method === "GET" && p.startsWith("/editor/")) {
|
|
766
1171
|
const f = safeJoin(path.join(EDITOR_DIR, "public"), p.slice(8));
|
|
767
|
-
return f ? serveFile(res, f) : text(res, 403, "Forbidden");
|
|
1172
|
+
return f ? serveFile(res, f, null, req) : text(res, 403, "Forbidden");
|
|
768
1173
|
}
|
|
1174
|
+
if ((p.startsWith("/deck/") || p.startsWith("/files/")) && !ok) return text(res, 403, "Not logged in");
|
|
769
1175
|
let snap;
|
|
770
|
-
if (req.method === "GET" && (snap = p.match(/^\/deck\/__pindeck-print-([\w-]+)\.html$/))) {
|
|
1176
|
+
if (req.method === "GET" && (snap = p.match(/^\/(?:deck|files)\/(?:.*\/)?__pindeck-print-([\w-]+)\.html$/))) {
|
|
771
1177
|
const html = snapshots.get(snap[1]);
|
|
772
1178
|
if (!html) return text(res, 404, "Not found");
|
|
773
1179
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
774
1180
|
return res.end(html);
|
|
775
1181
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1182
|
+
// /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
|
|
1183
|
+
// /files/, so that ../ paths reach the root: /deck/ alone redirects there.
|
|
1184
|
+
if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
|
|
1185
|
+
res.writeHead(302, { location: DECK_PAGE + url.search, "cache-control": "no-store" });
|
|
1186
|
+
return res.end();
|
|
1187
|
+
}
|
|
1188
|
+
const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
|
|
1189
|
+
: p.startsWith("/files/") && SERVE_ROOT ? servable(SERVE_ROOT, p.slice(7)) : undefined);
|
|
1190
|
+
if (deckPath === null) return text(res, 403, "Forbidden");
|
|
1191
|
+
if (deckPath) {
|
|
1192
|
+
const f = deckPath;
|
|
779
1193
|
const mode = url.searchParams.get("editor");
|
|
780
|
-
if (!mode ||
|
|
1194
|
+
if (!mode || !/\.html?$/i.test(f)) return serveFile(res, f, null, req);
|
|
781
1195
|
return serveFile(res, f, (s) => {
|
|
782
1196
|
// headless runs must not stream media, or Chrome never finishes the page
|
|
783
1197
|
if (mode === "shot" || mode === "dump" || mode === "pdf") s = s.replace(/\bpreload="(auto|metadata)"/g, 'preload="none"');
|
|
784
|
-
if (mode === "pdf")
|
|
1198
|
+
if (mode === "pdf") {
|
|
1199
|
+
s = s.replace(/<\/head>/i, PDF_HEAD_TAG).replace(/<\/body>/i, PDF_PAGE_TAG);
|
|
1200
|
+
// 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
|
|
1201
|
+
return slideSelector(parse(s)) ? s.replace(/<\/body>/i, BRIDGE_TAG) : s;
|
|
1202
|
+
}
|
|
785
1203
|
return s.replace(/<\/body>/i, BRIDGE_TAG);
|
|
786
1204
|
});
|
|
787
1205
|
}
|
|
788
1206
|
|
|
789
|
-
// API: token required; POSTs must come from the editor page itself
|
|
1207
|
+
// API: token required (header or login cookie); POSTs must come from the editor page itself
|
|
790
1208
|
if (!p.startsWith("/api/")) return text(res, 404, "Not found");
|
|
791
|
-
|
|
792
|
-
if (tok !== TOKEN) return text(res, 403, "Bad token");
|
|
1209
|
+
if (!ok) return text(res, 403, "Bad token");
|
|
793
1210
|
const origin = req.headers.origin;
|
|
794
|
-
if (req.method === "POST" && origin && origin !==
|
|
1211
|
+
if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`)
|
|
795
1212
|
return text(res, 403, "Bad origin");
|
|
796
1213
|
|
|
797
1214
|
if (p === "/api/events") {
|
|
798
1215
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
799
|
-
res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(),
|
|
1216
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ chat: state.chat, status: status(), rev: deckRev(),
|
|
800
1217
|
turns: state.turns.map((t) => ({ id: t.id, undone: t.undone })) })}\n\n`);
|
|
801
1218
|
clients.add(res);
|
|
802
1219
|
req.on("close", () => clients.delete(res));
|
|
803
1220
|
return;
|
|
804
1221
|
}
|
|
805
1222
|
if (p === "/api/ping") return json(res, 200, { ok: true });
|
|
1223
|
+
if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000)) });
|
|
1224
|
+
if (p === "/api/status") return json(res, 200, status());
|
|
806
1225
|
if (p === "/api/current" && req.method === "POST") { state.current = await body(req); return json(res, 200, {}); }
|
|
807
1226
|
if (p === "/api/pins" && req.method === "POST") { state.pins = (await body(req)).pins || []; return json(res, 200, {}); }
|
|
808
1227
|
if (p === "/api/measure-result" && req.method === "POST") {
|
|
@@ -814,6 +1233,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
814
1233
|
|
|
815
1234
|
if (p === "/api/message" && req.method === "POST") {
|
|
816
1235
|
if (state.busy) return json(res, 409, { error: "Claude is still working." });
|
|
1236
|
+
// checked again here: the claude command may have been installed (or removed) since the start
|
|
1237
|
+
const claude = detectCapabilities().claude;
|
|
1238
|
+
if (!claude.available) return json(res, 503, { code: "claude-unavailable", error: claude.reason });
|
|
817
1239
|
const b = await body(req);
|
|
818
1240
|
if (typeof b.text !== "string" || !b.text.trim()) return json(res, 400, { error: "Empty message." });
|
|
819
1241
|
// what the prompt reads from pins and the selection must be there
|
|
@@ -853,18 +1275,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
853
1275
|
const buf = Buffer.concat(chunks);
|
|
854
1276
|
if (kind === "pdf") {
|
|
855
1277
|
if (buf.subarray(0, 5).toString() !== "%PDF-") return json(res, 415, { error: `${original} is not a PDF.` });
|
|
856
|
-
fs.mkdirSync(PDF_DIR, { recursive: true });
|
|
1278
|
+
fs.mkdirSync(PDF_DIR, { recursive: true, mode: 0o700 });
|
|
857
1279
|
const abs = attachPath(original, PDF_DIR);
|
|
858
|
-
fs.writeFileSync(abs, buf);
|
|
1280
|
+
fs.writeFileSync(abs, buf, { mode: 0o600 });
|
|
859
1281
|
state.uploads.add(abs);
|
|
860
1282
|
let info = null;
|
|
861
|
-
if (
|
|
1283
|
+
if (CAPS.pdfPages.available) { try { info = await pdfInfo(abs, STATE_DIR); } catch {} }
|
|
862
1284
|
return json(res, 200, { path: abs, kind, bytes: buf.length, pages: info?.pages, pageSize: info?.sizes[0] });
|
|
863
1285
|
}
|
|
864
1286
|
const rel = attachPath(original);
|
|
865
|
-
|
|
866
|
-
ourWrites.set(rel, { hash: hash(buf.toString("utf8")), manual: true }); // no slide reload for a new file
|
|
867
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), buf);
|
|
1287
|
+
writeDeckFile(rel, buf);
|
|
868
1288
|
state.uploads.add(rel);
|
|
869
1289
|
return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
|
|
870
1290
|
}
|
|
@@ -886,7 +1306,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
886
1306
|
Object.assign(settings, { model, effort });
|
|
887
1307
|
saveSettings();
|
|
888
1308
|
// restart the session with the new flags; --resume keeps the conversation
|
|
889
|
-
if (session.running)
|
|
1309
|
+
if (session.running) session.stop().then(() => { if (!quitting) ensureSession(); });
|
|
890
1310
|
addEntry({ kind: "notice", text: `Model: ${model[0].toUpperCase() + model.slice(1)}, effort: ${effort}. The conversation continues.` });
|
|
891
1311
|
pushStatus();
|
|
892
1312
|
return json(res, 200, { settings });
|
|
@@ -894,21 +1314,25 @@ const server = http.createServer(async (req, res) => {
|
|
|
894
1314
|
if (p === "/api/slide-hidden" && req.method === "POST") {
|
|
895
1315
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
896
1316
|
const b = await body(req);
|
|
1317
|
+
if (staleEdit(res, b)) return;
|
|
897
1318
|
const before = snapshot(EDITABLE);
|
|
898
|
-
const
|
|
1319
|
+
const tree = parse(before[DECK_FILE]), slides = findSlides(tree);
|
|
899
1320
|
const index = slides.findIndex((s) => s.attrs["data-eid"]?.value === b.eid);
|
|
900
1321
|
if (index < 0) return json(res, 404, { error: "Slide not found in the file." });
|
|
901
1322
|
const r = setAttr(before[DECK_FILE], b.eid, "data-visibility", b.hidden ? "hidden" : null);
|
|
902
1323
|
if (!r.changed) return json(res, 200, { unchanged: true });
|
|
1324
|
+
const skipped = slideSelector(tree) ? "left out of PDF exports (whether presenting skips them depends on the deck's script)"
|
|
1325
|
+
: "skipped when presenting and in PDF exports";
|
|
903
1326
|
const turnId = recordHandEdit(before, r.html, `${b.hidden ? "Hid" : "Showed"} slide ${index + 1}`,
|
|
904
1327
|
`The user ${b.hidden ? "hid" : "showed again"} slide ${index + 1} (${b.eid}). Hidden slides carry ` +
|
|
905
|
-
`data-visibility="hidden": they stay in the file but are skipped
|
|
1328
|
+
`data-visibility="hidden": they stay in the file but are ${skipped}.`,
|
|
906
1329
|
{ manual: true, chat: false }); // the editor reloads the slide itself; undo is in the toolbar
|
|
907
1330
|
return json(res, 200, { turnId, index });
|
|
908
1331
|
}
|
|
909
1332
|
if (p === "/api/move-slide" && req.method === "POST") {
|
|
910
1333
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
911
1334
|
const b = await body(req);
|
|
1335
|
+
if (staleEdit(res, b)) return;
|
|
912
1336
|
const before = snapshot(EDITABLE);
|
|
913
1337
|
const r = moveSlide(before[DECK_FILE], b.eid, b.before ?? null);
|
|
914
1338
|
if (r.error) return json(res, 409, { error: r.error });
|
|
@@ -921,6 +1345,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
921
1345
|
if (p === "/api/add-slide" && req.method === "POST") {
|
|
922
1346
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
923
1347
|
const b = await body(req);
|
|
1348
|
+
if (staleEdit(res, b)) return;
|
|
924
1349
|
if (!["content", "section", "duplicate"].includes(b.kind)) return json(res, 400, { error: "Unknown slide kind." });
|
|
925
1350
|
const before = snapshot(EDITABLE);
|
|
926
1351
|
const r = insertSlide(before[DECK_FILE], b.kind, b.after);
|
|
@@ -935,6 +1360,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
935
1360
|
return json(res, 200, { turnId, index: r.index, eid });
|
|
936
1361
|
}
|
|
937
1362
|
if (p === "/api/import" && req.method === "POST") {
|
|
1363
|
+
if (!CAPS.pptxImport.available) return json(res, 501, { code: "unavailable", error: CAPS.pptxImport.reason });
|
|
938
1364
|
const name = path.basename(decodeURIComponent(String(req.headers["x-filename"] || "slides.pptx")));
|
|
939
1365
|
if (!/\.pptx$/i.test(name)) return json(res, 415, { error: "Choose a PowerPoint file (.pptx). Save older .ppt files as .pptx first." });
|
|
940
1366
|
const chunks = [];
|
|
@@ -960,6 +1386,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
960
1386
|
if (p === "/api/import-apply" && req.method === "POST") {
|
|
961
1387
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
962
1388
|
const b = await body(req);
|
|
1389
|
+
if (staleEdit(res, b)) return;
|
|
963
1390
|
const imp = lastImport;
|
|
964
1391
|
if (!imp || imp.id !== b.id) return json(res, 410, { error: "This import has expired. Choose the file again." });
|
|
965
1392
|
const numbers = (b.slides || []).filter((n) => imp.model.slides.some((s) => s.n === n));
|
|
@@ -990,7 +1417,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
990
1417
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/diff$/))) {
|
|
991
1418
|
const t = state.turns.find((t) => t.id === +m[1]);
|
|
992
1419
|
if (!t) return text(res, 404, "No such change");
|
|
993
|
-
return text(res, 200,
|
|
1420
|
+
return text(res, 200, changeFiles(t).map((rel) => fileDiff(rel, t.before[rel] ?? null, t.after[rel] ?? null)).filter(Boolean).join("\n\n"));
|
|
994
1421
|
}
|
|
995
1422
|
if ((m = p.match(/^\/api\/turn\/(\d+)\/undo$/)) && req.method === "POST") {
|
|
996
1423
|
const t = liveTurns().at(-1);
|
|
@@ -1002,18 +1429,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1002
1429
|
if (state.busy) return json(res, 409, { error: "Wait until Claude has finished." });
|
|
1003
1430
|
const t = state.redo.at(-1);
|
|
1004
1431
|
if (!t) return json(res, 409, { error: "Nothing to redo." });
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
pushStatus();
|
|
1008
|
-
return json(res, 409, { error: "The files changed since the undo, so it cannot be redone." });
|
|
1009
|
-
}
|
|
1432
|
+
const diverged = undoConflicts(t, "redo");
|
|
1433
|
+
if (diverged.length) return conflict(res, "redo", diverged);
|
|
1010
1434
|
state.redo.pop();
|
|
1011
|
-
for (const
|
|
1435
|
+
for (const rel of changeFiles(t)) if (!isAs(rel, t.after[rel])) putDeckFile(rel, t.after[rel] ?? null);
|
|
1012
1436
|
for (const { rel } of t.deletedFiles || []) fs.rmSync(path.join(DECK_DIR, rel), { force: true });
|
|
1013
|
-
for (const { rel, data } of t.addedFiles || [])
|
|
1014
|
-
fs.mkdirSync(path.dirname(path.join(DECK_DIR, rel)), { recursive: true });
|
|
1015
|
-
fs.writeFileSync(path.join(DECK_DIR, rel), data);
|
|
1016
|
-
}
|
|
1437
|
+
for (const { rel, data } of t.addedFiles || []) writeDeckFile(rel, data, false);
|
|
1017
1438
|
t.undone = false;
|
|
1018
1439
|
state.notes.push(t.kind === "manual" ? `The user redid their hand edit: ${t.label}.`
|
|
1019
1440
|
: `The user redid your change from turn ${t.id} after undoing it.`);
|
|
@@ -1031,6 +1452,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1031
1452
|
if (p === "/api/delete" && req.method === "POST") {
|
|
1032
1453
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1033
1454
|
const b = await body(req);
|
|
1455
|
+
if (staleEdit(res, b)) return;
|
|
1034
1456
|
const plan = deletePlan(b.eid);
|
|
1035
1457
|
if (!plan) return json(res, 404, { error: "Element not found in the file." });
|
|
1036
1458
|
const before = snapshot(EDITABLE);
|
|
@@ -1050,6 +1472,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1050
1472
|
if (p === "/api/edit" && req.method === "POST") {
|
|
1051
1473
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1052
1474
|
const b = await body(req);
|
|
1475
|
+
if (staleEdit(res, b)) return;
|
|
1053
1476
|
const before = snapshot(EDITABLE);
|
|
1054
1477
|
const r = setStyles(before[DECK_FILE], b.ops || []);
|
|
1055
1478
|
if (r.missing.length) return json(res, 404, { error: `No element ${r.missing.join(", ")} in ${DECK_FILE}.` });
|
|
@@ -1071,6 +1494,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1071
1494
|
if (p === "/api/notes" && req.method === "POST") {
|
|
1072
1495
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1073
1496
|
const b = await body(req);
|
|
1497
|
+
if (staleEdit(res, b)) return;
|
|
1074
1498
|
const before = snapshot(EDITABLE);
|
|
1075
1499
|
const r = setNotes(before[DECK_FILE], b.eid, b.text, !!b.html);
|
|
1076
1500
|
if (!r) return json(res, 404, { error: "Slide not found in the file." });
|
|
@@ -1085,6 +1509,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1085
1509
|
if (p === "/api/text" && req.method === "POST") {
|
|
1086
1510
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1087
1511
|
const b = await body(req);
|
|
1512
|
+
if (staleEdit(res, b)) return;
|
|
1088
1513
|
const before = snapshot(EDITABLE);
|
|
1089
1514
|
const r = setInner(before[DECK_FILE], b.eid, b.path || [], String(b.html ?? ""));
|
|
1090
1515
|
if (!r) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1100,6 +1525,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1100
1525
|
if (p === "/api/split" && req.method === "POST") {
|
|
1101
1526
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1102
1527
|
const b = await body(req);
|
|
1528
|
+
if (staleEdit(res, b)) return;
|
|
1103
1529
|
const before = snapshot(EDITABLE);
|
|
1104
1530
|
const r1 = setInner(before[DECK_FILE], b.eid, [], String(b.html ?? ""));
|
|
1105
1531
|
if (!r1) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1112,6 +1538,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1112
1538
|
if (p === "/api/remove" && req.method === "POST") {
|
|
1113
1539
|
if (state.busy) return json(res, 409, { error: "Claude is working. Wait until it has finished." });
|
|
1114
1540
|
const b = await body(req);
|
|
1541
|
+
if (staleEdit(res, b)) return;
|
|
1115
1542
|
const before = snapshot(EDITABLE);
|
|
1116
1543
|
const html = removeElement(before[DECK_FILE], b.eid);
|
|
1117
1544
|
if (html == null) return json(res, 404, { error: `No element ${b.eid} in ${DECK_FILE}.` });
|
|
@@ -1123,13 +1550,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
1123
1550
|
if (p === "/api/export" && req.method === "POST") {
|
|
1124
1551
|
const b = await body(req);
|
|
1125
1552
|
try { return json(res, 200, await exportPdf(b.kind, b.includeHidden)); }
|
|
1126
|
-
catch (e) { return json(res, 500, { error: String(e.message || e) }); }
|
|
1553
|
+
catch (e) { return json(res, e.status || 500, { error: String(e.message || e) }); }
|
|
1127
1554
|
}
|
|
1128
1555
|
|
|
1129
1556
|
// CLI endpoints (used by Claude Code via cli.mjs)
|
|
1130
1557
|
if (p === "/api/pdf") {
|
|
1131
1558
|
const kind = url.searchParams.get("slide") || url.searchParams.get("kind") || "handout";
|
|
1132
1559
|
if (!["handout", "presentation"].includes(kind)) return text(res, 400, 'Use "handout" or "presentation".');
|
|
1560
|
+
if (!CAPS.pdfExport.available) return text(res, 501, `PDF export is not available: ${CAPS.pdfExport.reason}.`);
|
|
1133
1561
|
const r = await exportPdf(kind, url.searchParams.get("all") === "1");
|
|
1134
1562
|
return text(res, 200, r.path);
|
|
1135
1563
|
}
|
|
@@ -1138,15 +1566,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1138
1566
|
return text(res, r.status || 200, r.text);
|
|
1139
1567
|
}
|
|
1140
1568
|
if (p === "/api/slides") {
|
|
1141
|
-
|
|
1569
|
+
const list = slideList(), why = !list.length && deckProblemNow();
|
|
1570
|
+
if (why) return text(res, 409, why);
|
|
1571
|
+
return text(res, 200, list.map((s) => `${String(s.index + 1).padStart(2)} ${s.eid}`).join("\n"));
|
|
1142
1572
|
}
|
|
1143
1573
|
if (p === "/api/layout" || p === "/api/shot") {
|
|
1144
1574
|
const slide = resolveSlide(url.searchParams.get("slide"));
|
|
1145
|
-
if (!slide) return text(res, 404, `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1575
|
+
if (!slide) return text(res, 404, deckProblemNow() || `No slide "${url.searchParams.get("slide")}". Use \`${CLI_CMD} slides\`.`);
|
|
1146
1576
|
if (!slide.eid) runEidPass();
|
|
1147
1577
|
const s2 = slide.eid ? slide : resolveSlide(String(slide.index + 1));
|
|
1148
|
-
if (p === "/api/shot")
|
|
1149
|
-
|
|
1578
|
+
if (p === "/api/shot") {
|
|
1579
|
+
if (!CAPS.screenshot.available) return text(res, 501, `Screenshots are not available: ${CAPS.screenshot.reason}.`);
|
|
1580
|
+
return text(res, 200, await takeShot(s2));
|
|
1581
|
+
}
|
|
1582
|
+
try { return text(res, 200, formatLayout(await measure(s2))); }
|
|
1583
|
+
catch (e) { if (e.status) return text(res, e.status, e.message); throw e; }
|
|
1150
1584
|
}
|
|
1151
1585
|
return text(res, 404, "Not found");
|
|
1152
1586
|
} catch (e) {
|
|
@@ -1157,15 +1591,56 @@ const server = http.createServer(async (req, res) => {
|
|
|
1157
1591
|
}
|
|
1158
1592
|
});
|
|
1159
1593
|
|
|
1160
|
-
/* ---- Start
|
|
1161
|
-
|
|
1162
|
-
|
|
1594
|
+
/* ---- Start ----------------------------------------------------------------------------------------
|
|
1595
|
+
The port is bound first. Only then does PinDeck write anything: its state folder and server files, and
|
|
1596
|
+
the element ids in the deck. A port in use or another failure to listen leaves deck and state untouched. */
|
|
1597
|
+
let quitting = false;
|
|
1598
|
+
server.on("error", (e) => {
|
|
1599
|
+
if (ready) return console.error(e);
|
|
1600
|
+
console.error(e.code === "EADDRINUSE"
|
|
1601
|
+
? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
|
|
1602
|
+
: `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
1603
|
+
process.exit(1);
|
|
1604
|
+
});
|
|
1163
1605
|
server.listen(PORT, "127.0.0.1", () => {
|
|
1164
|
-
|
|
1606
|
+
PORT = server.address().port;
|
|
1607
|
+
ORIGIN = `http://127.0.0.1:${PORT}`;
|
|
1608
|
+
try {
|
|
1609
|
+
writeServerInfo();
|
|
1610
|
+
completeSettings();
|
|
1611
|
+
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
1612
|
+
const first = runEidPass();
|
|
1613
|
+
if (first.changed) console.log(`Added ${first.assigned.length} element ids to ${DECK_FILE} (backup in ${path.join(STATE_DIR, "backups")}).`);
|
|
1614
|
+
startWatcher();
|
|
1615
|
+
} catch (e) {
|
|
1616
|
+
console.error(`PinDeck could not open the deck: ${e.message}`);
|
|
1617
|
+
removeServerInfo();
|
|
1618
|
+
process.exit(1);
|
|
1619
|
+
}
|
|
1620
|
+
ready = true;
|
|
1621
|
+
console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
|
|
1622
|
+
console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
|
|
1165
1623
|
console.log(`Deck: ${DECK_DIR}`);
|
|
1166
|
-
|
|
1624
|
+
console.log(`Server: ${CAPS.label}`);
|
|
1625
|
+
for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
|
|
1626
|
+
if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
|
|
1167
1627
|
ensureSession();
|
|
1168
1628
|
});
|
|
1169
|
-
|
|
1629
|
+
/* Ctrl+C or SIGTERM: stop taking requests, let Claude Code end (it gets end of input, then SIGTERM, then
|
|
1630
|
+
SIGKILL), then exit. At most 6 s; a second Ctrl+C exits at once. A running turn is not resumed later. */
|
|
1631
|
+
async function quit() {
|
|
1632
|
+
if (quitting) process.exit(1);
|
|
1633
|
+
quitting = true;
|
|
1634
|
+
try { watcher?.close(); } catch {}
|
|
1635
|
+
clearTimeout(flush); clearTimeout(eidTimer);
|
|
1636
|
+
for (const res of clients) res.end();
|
|
1637
|
+
for (const w of pending.values()) { clearTimeout(w.t); w.reject(new Error("PinDeck is stopping")); }
|
|
1638
|
+
server.close();
|
|
1639
|
+
server.closeAllConnections?.();
|
|
1640
|
+
removeServerInfo();
|
|
1641
|
+
await Promise.race([session.stop(), new Promise((r) => setTimeout(r, 6000))]);
|
|
1642
|
+
if (chatTimer) writeChat();
|
|
1643
|
+
process.exit(0);
|
|
1644
|
+
}
|
|
1170
1645
|
process.on("SIGINT", quit);
|
|
1171
1646
|
process.on("SIGTERM", quit);
|