pindeck 3.0.0-beta.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # PinDeck
2
2
 
3
- > **macOS, and Linux as a beta · Version 3 beta preview** (`3.0.0-beta.3`). On Linux, PinDeck runs as a
3
+ > **macOS, and Linux as a beta · Version 3 beta preview** (`3.1.0-beta.1`). On Linux, PinDeck runs as a
4
4
  > headless server that you use from your Mac's browser through an SSH tunnel (see
5
5
  > [Running on a Linux server](#running-on-a-linux-server-beta)). Windows is not supported. The beta is on
6
6
  > npm’s `beta` channel and the [`v3-beta`](https://github.com/ehabets/PinDeck/tree/v3-beta) branch; stable
@@ -42,7 +42,7 @@ Install the beta from npm:
42
42
  ```bash
43
43
  npx pindeck@beta /path/to/deck
44
44
  # Or select this exact beta:
45
- npx pindeck@3.0.0-beta.3 /path/to/deck
45
+ npx pindeck@3.1.0-beta.1 /path/to/deck
46
46
  ```
47
47
 
48
48
  Or run it from the `v3-beta` branch, which includes the example deck:
@@ -50,14 +50,14 @@ Or run it from the `v3-beta` branch, which includes the example deck:
50
50
  ```bash
51
51
  git clone --branch v3-beta --single-branch https://github.com/ehabets/PinDeck.git PinDeck-v3
52
52
  cd PinDeck-v3
53
- node server.mjs ocean-example
53
+ node front.mjs ocean-example
54
54
  ```
55
55
 
56
56
  Use `npx pindeck@latest /path/to/deck` for the stable release; the beta channel does not replace it.
57
57
  Releases, with notes for each beta, are on [GitHub](https://github.com/ehabets/PinDeck/releases).
58
58
 
59
59
  Then open the login link PinDeck prints (`PinDeck: http://127.0.0.1:5180/login?code=...`). It works
60
- once and logs this browser in; for a new one, run `pindeck login` (or `node server.mjs login`).
60
+ once and logs this browser in; for a new one, run `pindeck login` (or `node front.mjs login`).
61
61
 
62
62
  The deck is a file (`pindeck path/to/talk.html`) or a folder, which opens its `index.html`; without
63
63
  one, the current folder. Several decks can share a folder, each with its own chat and undo history;
@@ -68,6 +68,17 @@ model in `~/.pindeck-v3/config.json`), `--asset-root <folder>` (see [Files outsi
68
68
  `--help`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck-v3/` (change with
69
69
  `PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
70
70
 
71
+ ### Several decks
72
+
73
+ Click the deck's name above the slide for the other decks: the `.html` files in the deck's folder and in
74
+ the asset roots, and in their folders one level down (`lecture/index.html`). A deck opens in the same
75
+ tab, or with ⌘-click in a new one, on the same port and login, so one SSH tunnel serves them all. Each keeps
76
+ its own chat and undo history while it is open, and the assistant finishes a turn in a deck you leave. A
77
+ file PinDeck cannot open is greyed out with the reason; listing a deck changes nothing, and only opening it
78
+ adds its element ids. Started with a folder that has no `index.html`, PinDeck shows the list of its decks.
79
+ A deck with no editor open for 30 minutes is closed (its undo history goes, as on a restart) and opens
80
+ again when you choose it.
81
+
71
82
  ## Compatibility and requirements
72
83
 
73
84
  * **Runtime:** Node.js 20 or later, plus a modern browser. The release workflow uses Node.js 24.
@@ -242,7 +253,7 @@ The v3 preview defaults to **`~/.pindeck-v3`**, keeping v2 settings and conversa
242
253
  not copy the v2 directory automatically. To run both versions at once, use another port, for example:
243
254
 
244
255
  ```bash
245
- node server.mjs /path/to/a/deck-copy --port 5183 --provider codex
256
+ node front.mjs /path/to/a/deck-copy --port 5183 --provider codex
246
257
  ```
247
258
 
248
259
  ## Backend permissions and attachments
@@ -284,7 +295,7 @@ the `claude` or `codex` CLI installed and logged in under your account there, if
284
295
  editing by hand still works. Then, in one SSH session on the server:
285
296
 
286
297
  ```bash
287
- node server.mjs path/to/deck --port 5180
298
+ node front.mjs path/to/deck --port 5180
288
299
  ```
289
300
 
290
301
  and on your Mac, in another terminal (`myserver` is your usual SSH host):
@@ -297,7 +308,7 @@ Open the login link PinDeck printed on the server in the Mac's browser. The loca
297
308
  the server's: with `-L 127.0.0.1:8080:127.0.0.1:5180`, change 5180 in the link to 8080. PinDeck accepts any
298
309
  port, as long as the address is `127.0.0.1` or `localhost`. If the tunnel drops, a message you have already sent keeps running on the
299
310
  server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
300
- message twice. If PinDeck restarts, the editor asks you to log in again: run `node server.mjs login
311
+ message twice. If PinDeck restarts, the editor asks you to log in again: run `node front.mjs login
301
312
  path/to/deck` on the server for a new link.
302
313
 
303
314
  Other users of a shared server cannot open the editor or read the deck through PinDeck: the token that
@@ -411,6 +422,10 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
411
422
 
412
423
  ## How it works
413
424
 
425
+ * **One port, a server per deck.** `pindeck` starts the front (`front.mjs`): it holds the port, the login
426
+ and the deck list, and serves each open deck at `/d/<deck>/`. Every deck runs its own deck server
427
+ (`server.mjs`) on a private port of 127.0.0.1 with a token of its own; the front passes each request on
428
+ with that token. A deck's server, its assistant and its undo history are those of that deck alone.
414
429
  * **Element ids.** On start, the server gives every slide and every visible element a readable
415
430
  `data-eid` attribute (`summary`, `summary/callout`, `summary/li-2`). Only the attribute is
416
431
  inserted; the rest of the file stays as it was. A backup of the file before the first change is
@@ -462,7 +477,9 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
462
477
 
463
478
  | File | Purpose |
464
479
  |---|---|
465
- | `server.mjs` | Local server (127.0.0.1 only, random token per run, one-time login links): UI, deck files, events, backend session, undo, CLI endpoints, `login`. |
480
+ | `front.mjs` | What `pindeck` starts (127.0.0.1 only, random token per run, one-time login links): the login, the deck list and one deck server per open deck at `/d/<deck>/`; `login`. |
481
+ | `server.mjs` | The server for one deck: UI, deck files, events, backend session, undo, CLI endpoints. Behind the front, or on its own (`node server.mjs <deck>`). |
482
+ | `lib/login.mjs`, `lib/deck-target.mjs`, `lib/deck-list.mjs` | Login links and cookies, which deck a path names (and its state folder), and the decks the menu lists. |
466
483
  | `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for the backends (and for you), and `git-setup`. |
467
484
  | `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
468
485
  | `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
@@ -491,13 +508,25 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
491
508
  * Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
492
509
  * No indenting or outdenting of bullets with `Tab` yet, and `Backspace` at the start of a bullet does not merge it with the one above; ask the assistant for those.
493
510
  * Moves are offsets (`translate`), so the space an element came from stays reserved. Ask the assistant to turn hand edits into proper CSS when a layout should really change.
494
- * The undo history lives on the server and starts fresh when PinDeck restarts.
511
+ * The undo history lives on the server and starts fresh when PinDeck restarts, or when a deck closes after
512
+ 30 minutes without an open editor.
495
513
  * One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
496
514
  * Linux (beta): pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
497
515
  Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server-beta)).
498
516
 
499
517
  ## Versions
500
518
 
519
+ * **3.1.0-beta.1**: several decks on one port, and screenshots you can open from the chat.
520
+ - A deck menu: click the deck's name above the slide to open another deck from its folder or the
521
+ asset roots (and one folder down). Each open deck keeps its own chat, assistant session and Undo;
522
+ listing decks changes no file. `pindeck` now starts a small front that holds the port and the
523
+ login and runs one deck server per open deck. A deck with no editor open for 30 minutes closes
524
+ (its Undo history goes, as on a restart) and reopens when chosen again.
525
+ - A folder without `index.html` opens a page listing its decks instead of failing.
526
+ - `pindeck login talk.html` prints a link that opens that deck.
527
+ - Screenshots the assistant took show as a small picture on the chat line; click it to see it large.
528
+ A screenshot PinDeck no longer keeps says so.
529
+
501
530
  * **3.0.0-beta.3**: everything from 2.10.0, for both assistants.
502
531
  - Decks made without reveal.js: a deck that names its slides with `<meta name="pindeck:slides">`
503
532
  and their size with `<meta name="pindeck:size">` is edited like a reveal.js deck (contributed by
package/front.mjs ADDED
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ /* PinDeck: the front, which is what `pindeck` starts.
3
+
4
+ pindeck [deck-dir | deck.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <dir>]
5
+ pindeck login [deck-dir | deck.html]
6
+
7
+ One port and one login for several decks. Each deck that is open runs its own deck server (server.mjs, on
8
+ a private port of 127.0.0.1 with a token of its own) and is reached here as /d/<deck key>/: the front logs
9
+ the browser in, then passes each request on with that deck's token. A deck starts when it is first asked
10
+ for (from the editor's deck menu, or a bookmark), so its chat, Claude session and undo stay while it is
11
+ open, and Claude can go on working in one deck while you edit another. A deck with no editor open for
12
+ PINDECK_IDLE_MS (30 minutes) is closed; it opens again when asked for.
13
+
14
+ The menu lists the decks in the start folder and the asset roots, and in their folders one level down
15
+ (lib/deck-list.mjs). Only those can be opened: there is no free path entry. */
16
+
17
+ import http from "node:http";
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import crypto from "node:crypto";
22
+ import { spawn } from "node:child_process";
23
+ import { fileURLToPath } from "node:url";
24
+ import { isSupportedPlatform, detectCapabilities, publicCapabilities, parsePort } from "./lib/capabilities.mjs";
25
+ import { deckTarget, homeDir } from "./lib/deck-target.mjs";
26
+ import { goodHost, LOGIN_PAGE, createLogin } from "./lib/login.mjs";
27
+ import { listDecks } from "./lib/deck-list.mjs";
28
+ import { USAGE } from "./lib/usage.mjs";
29
+
30
+ const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
31
+ const SERVER = path.join(EDITOR_DIR, "server.mjs");
32
+ const HOME_DIR = homeDir();
33
+ const IDLE_MS = Number(process.env.PINDECK_IDLE_MS) || 30 * 60000;
34
+
35
+ const argv = process.argv.slice(2);
36
+ if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
37
+ const opt = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv.splice(i, 2)[1] : dflt; };
38
+ const portArg = opt("--port", process.env.PORT || 5180);
39
+ const PASS = []; // what every deck server gets: the backend and the asset roots
40
+ for (const name of ["--provider", "--model"]) { const v = opt(name); if (v !== undefined) PASS.push(name, v); }
41
+ const assetArgs = [];
42
+ for (let a; (a = opt("--asset-root")) !== undefined;) { assetArgs.push(a); PASS.push("--asset-root", a); }
43
+ let PORT = parsePort(portArg); // the port actually bound once listening (--port 0 picks a free one)
44
+ if (PORT === null) { console.error(`Invalid port "${portArg}": use a number from 1 to 65535.\n\n${USAGE}`); process.exit(2); }
45
+ if (!isSupportedPlatform(process.platform)) {
46
+ const { showPlatformNotice } = await import("./lib/platform-notice.mjs");
47
+ await showPlatformNotice(PORT);
48
+ process.exit(0);
49
+ }
50
+
51
+ /* pindeck login: a new one-time login link from the running front, asked with the token in its server file
52
+ (readable only by this user). With a deck, the link opens that deck. A deck server started on its own
53
+ (node server.mjs) for that deck answers instead, as before the front. */
54
+ const SERVER_FILE = path.join(HOME_DIR, "server.json");
55
+ if (argv[0] === "login") {
56
+ const deck = argv[1] ? deckTarget(argv[1]) : null;
57
+ let info = null;
58
+ for (const f of [...(deck ? [path.join(HOME_DIR, "decks", deck.key, "server.json")] : []), SERVER_FILE]) {
59
+ try { info = JSON.parse(fs.readFileSync(f, "utf8")); break; } catch {}
60
+ }
61
+ if (!info) { console.error("No PinDeck is running."); process.exit(1); }
62
+ const next = deck && info.front ? `/d/${deck.key}/` : "/";
63
+ try {
64
+ const res = await fetch(`${info.url}/api/login-link?` + new URLSearchParams({ next }), { headers: { "x-editor-token": info.token } });
65
+ if (!res.ok) throw new Error(res.status);
66
+ console.log(`PinDeck (${deck ? path.join(deck.dir, deck.file) : info.deckDir}): ${(await res.json()).url}`);
67
+ process.exit(0);
68
+ } catch {
69
+ console.error(`The PinDeck at ${info.url} does not answer. Is it still running?`);
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ /* The start deck, and the folders whose decks the menu offers. A folder without index.html opens the list
75
+ of its decks; if it has none, its deck server says why, as for any deck that cannot be opened. */
76
+ const START = deckTarget(argv[0] || process.cwd());
77
+ const expandHome = (p) => (p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p);
78
+ let fileConfig = {};
79
+ try { fileConfig = JSON.parse(fs.readFileSync(path.join(HOME_DIR, "config.json"), "utf8")); } catch {}
80
+ const ROOTS = [START.dir, ...assetArgs, ...(Array.isArray(fileConfig.assetRoots) ? fileConfig.assetRoots : [])]
81
+ .map((r) => path.resolve(expandHome(String(r))))
82
+ .filter((r, i, all) => all.indexOf(r) === i && (() => { try { return fs.statSync(r).isDirectory(); } catch { return false; } })());
83
+ const decksNow = () => listDecks(ROOTS);
84
+ const chooser = !START.named && !fs.existsSync(path.join(START.dir, START.file)) && decksNow().some((d) => !d.problem);
85
+
86
+ const TOKEN = crypto.randomBytes(16).toString("hex");
87
+ let ORIGIN = `http://127.0.0.1:${PORT}`; // set again from the bound port once listening
88
+ const { loginCode, loginUrl, authed, login } = createLogin({ token: TOKEN, port: () => PORT, origin: () => ORIGIN });
89
+ const CAPS = detectCapabilities();
90
+
91
+ /* ---- Deck servers -------------------------------------------------------------------------------- */
92
+ const decks = new Map(); // key -> { key, dir, file, label, proc, url, token, ready, exited, sse, last }
93
+ const labelOf = (d) => (d.file === "index.html" ? path.basename(d.dir) : d.file);
94
+ let quitting = false;
95
+
96
+ /* Its output goes to this terminal, each line marked with the deck. hold(): keep the lines back for now */
97
+ function forward(stream, label, to, keep, hold = () => false) {
98
+ let rest = "";
99
+ const put = (l) => { keep?.(l + "\n"); if (!hold()) to.write(`[${label}] ${l}\n`); };
100
+ stream.on("data", (chunk) => {
101
+ const lines = (rest + chunk).split("\n");
102
+ rest = lines.pop();
103
+ lines.forEach(put);
104
+ });
105
+ stream.on("end", () => { if (rest) put(rest); });
106
+ }
107
+
108
+ /* The deck's server, started once: the promise resolves when it listens, or rejects with what it printed.
109
+ The start deck's complaints are printed by the front as they are, like those of PinDeck before the front. */
110
+ function openDeck(entry, start = false) {
111
+ const known = decks.get(entry.key);
112
+ if (known) return known.ready;
113
+ const d = { key: entry.key, dir: entry.dir, file: entry.file, label: labelOf(entry), sse: 0, last: Date.now() };
114
+ decks.set(d.key, d);
115
+ // its own process group: Ctrl+C reaches the front only, which then ends every deck once
116
+ d.proc = spawn(process.execPath, [SERVER, entry.file === "index.html" ? entry.dir : path.join(entry.dir, entry.file),
117
+ "--port", "0", ...PASS], { env: { ...process.env, PINDECK_FRONT: "1" }, stdio: ["ignore", "pipe", "pipe", "ipc"], detached: true });
118
+ let said = "";
119
+ forward(d.proc.stdout, d.label, process.stdout);
120
+ forward(d.proc.stderr, d.label, process.stderr, (l) => { said += l; }, () => start && !d.url);
121
+ d.exited = new Promise((r) => d.proc.once("exit", r));
122
+ d.ready = new Promise((resolve, reject) => {
123
+ d.proc.on("message", (m) => {
124
+ if (!m?.ready) return;
125
+ Object.assign(d, { url: m.ready.url, token: m.ready.token });
126
+ if (!start) console.log(`[${d.label}] opened: ${path.join(d.dir, d.file)}`);
127
+ resolve(d);
128
+ });
129
+ d.proc.once("exit", (code, signal) => {
130
+ if (decks.get(d.key) === d) decks.delete(d.key);
131
+ if (d.url && !quitting) console.log(`[${d.label}] closed`);
132
+ reject(Object.assign(new Error(said.trim() || `The deck server stopped (${code ?? signal}).`), { exitCode: code || 1 }));
133
+ });
134
+ });
135
+ d.ready.catch(() => {});
136
+ return d.ready;
137
+ }
138
+
139
+ function stopDeck(d) {
140
+ try { d.proc.kill("SIGTERM"); } catch {}
141
+ }
142
+
143
+ /* A deck with no editor open (no event stream) and nothing to do for IDLE_MS is closed */
144
+ setInterval(async () => {
145
+ for (const d of decks.values()) {
146
+ if (!d.url || d.sse > 0 || Date.now() - d.last < IDLE_MS) continue;
147
+ try {
148
+ const res = await fetch(`${d.url}/api/status`, { headers: { "x-editor-token": d.token } });
149
+ const st = await res.json();
150
+ if (st.busy || d.sse > 0 || Date.now() - d.last < IDLE_MS) continue;
151
+ } catch { continue; }
152
+ console.log(`[${d.label}] no editor open for ${Math.round(IDLE_MS / 60000)} min: closing it (it opens again when asked for)`);
153
+ stopDeck(d);
154
+ }
155
+ }, Math.min(60000, IDLE_MS / 2)).unref();
156
+
157
+ /* The request, without the front's cookie and with the deck's token, to the deck's server; the answer
158
+ streamed back (event streams, media ranges and large uploads included) */
159
+ function proxy(req, res, d, rest) {
160
+ d.last = Date.now();
161
+ const headers = { ...req.headers, host: new URL(d.url).host, "x-editor-token": d.token };
162
+ delete headers.cookie;
163
+ delete headers.origin; // checked here against the front's address; the deck server checks its own
164
+ const up = http.request(d.url + rest, { method: req.method, headers }, (r) => {
165
+ res.writeHead(r.statusCode, r.headers);
166
+ r.pipe(res);
167
+ });
168
+ up.on("error", () => { if (!res.headersSent) page(res, 502, "This deck stopped answering", "Reload the page to open it again."); else res.destroy(); });
169
+ if (rest.split("?")[0] === "/api/events") {
170
+ d.sse++;
171
+ res.on("close", () => { d.sse--; d.last = Date.now(); });
172
+ }
173
+ res.on("close", () => { if (!res.writableFinished) up.destroy(); });
174
+ req.pipe(up);
175
+ }
176
+
177
+ /* ---- HTTP ------------------------------------------------------------------------------------- */
178
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
179
+ const text = (res, code, s) => { res.writeHead(code, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); res.end(s); };
180
+ const json = (res, code, o) => { res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(o)); };
181
+ function page(res, code, title, body) {
182
+ res.writeHead(code, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
183
+ res.end(`<!doctype html><meta charset="utf-8"><title>${esc(title)} · PinDeck</title>
184
+ <link rel="icon" href="/editor/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/editor/front.css">
185
+ <main class="front"><h1>${esc(title)}</h1>${body}</main>`);
186
+ }
187
+ /* The decks for the menu (and the start page without a start deck), with which are open and working */
188
+ const deckList = () => decksNow().map((e) => ({ key: e.key, rel: e.rel, root: e.root, problem: e.problem,
189
+ open: !!decks.get(e.key)?.url }));
190
+ function chooserPage(res) {
191
+ const list = deckList(), groups = new Map(ROOTS.map((r) => [r, list.filter((d) => d.root === r)]).filter(([, l]) => l.length));
192
+ const body = [...groups].map(([root, list]) => `<h2>${esc(root)}</h2><ul>${list.map((d) => d.problem
193
+ ? `<li class="off" title="${esc(d.problem)}">${esc(d.rel)}<small>${esc(d.problem)}</small></li>`
194
+ : `<li><a href="/d/${esc(d.key)}/">${esc(d.rel)}</a>${d.open ? " <small>open</small>" : ""}</li>`).join("")}</ul>`).join("");
195
+ page(res, 200, "Decks", body || "<p>No decks here.</p>");
196
+ }
197
+ const MIME = { html: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8",
198
+ svg: "image/svg+xml", png: "image/png" };
199
+ function serveEditorFile(res, rel) {
200
+ const base = path.join(EDITOR_DIR, "public");
201
+ const f = path.resolve(base, "." + path.posix.normalize("/" + decodeURIComponent(rel)));
202
+ if (!f.startsWith(base + path.sep)) return text(res, 403, "Forbidden");
203
+ fs.readFile(f, (err, data) => {
204
+ if (err) return text(res, 404, "Not found");
205
+ res.writeHead(200, { "content-type": MIME[path.extname(f).slice(1)] || "application/octet-stream", "cache-control": "no-store" });
206
+ res.end(data);
207
+ });
208
+ }
209
+
210
+ const server = http.createServer(async (req, res) => {
211
+ const host = String(req.headers.host || "").toLowerCase();
212
+ if (!goodHost(host)) return text(res, 403, "Bad host");
213
+ const url = new URL(req.url, ORIGIN);
214
+ const p = url.pathname;
215
+ try {
216
+ if (req.method === "GET" && p === "/login") return login(res, url);
217
+ // the editor's own scripts and styles hold nothing secret; the deck page loads bridge.js from here
218
+ if (req.method === "GET" && p.startsWith("/editor/")) return serveEditorFile(res, p.slice(8));
219
+ if (!authed(req)) {
220
+ if (req.method === "GET" && /^\/(d\/[^/]+\/(index\.html)?)?$/.test(p)) {
221
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
222
+ return res.end(LOGIN_PAGE);
223
+ }
224
+ return text(res, 403, "Not logged in");
225
+ }
226
+ const origin = req.headers.origin;
227
+ if (req.method === "POST" && origin && origin.toLowerCase() !== `http://${host}`) return text(res, 403, "Bad origin");
228
+
229
+ if (p === "/") {
230
+ if (chooser) return chooserPage(res);
231
+ res.writeHead(302, { location: `/d/${START.key}/`, "cache-control": "no-store" });
232
+ return res.end();
233
+ }
234
+ if (p === "/api/ping") return json(res, 200, { ok: true, front: true });
235
+ if (p === "/api/login-link") return json(res, 200, { url: loginUrl(loginCode(3600000), url.searchParams.get("next") || "/") });
236
+ if (p === "/api/decks") return json(res, 200, { decks: deckList() });
237
+
238
+ const m = /^\/d\/([\w.-]+)(\/.*)?$/.exec(p);
239
+ if (!m) return text(res, 404, "Not found");
240
+ if (!m[2]) {
241
+ res.writeHead(301, { location: `/d/${m[1]}/${url.search}` });
242
+ return res.end();
243
+ }
244
+ let d = decks.get(m[1]);
245
+ if (!d) {
246
+ // only a deck from the list, and only one PinDeck can open
247
+ const entry = START.key === m[1] && !chooser ? { key: START.key, dir: START.dir, file: START.file }
248
+ : decksNow().find((e) => e.key === m[1]);
249
+ if (!entry) return page(res, 404, "No such deck", `<p>It is not in the deck folder or an asset root (any more).</p><p><a href="/">All decks</a></p>`);
250
+ if (entry.problem) return page(res, 409, "PinDeck cannot open this deck", `<p>${esc(entry.problem)}</p><p><a href="/">All decks</a></p>`);
251
+ d = entry;
252
+ }
253
+ try { d = await openDeck(d); }
254
+ catch (e) { return page(res, 502, "PinDeck could not open this deck", `<pre>${esc(e.message)}</pre><p><a href="/">All decks</a></p>`); }
255
+ return proxy(req, res, d, m[2] + url.search);
256
+ } catch (e) {
257
+ if (!res.headersSent) text(res, 500, String(e.message || e));
258
+ }
259
+ });
260
+
261
+ /* ---- Start ----------------------------------------------------------------------------------------
262
+ The port is bound first, then the start deck opens (its server checks it before writing anything). If it
263
+ cannot, the front says why and stops: as before the front, nothing is written. */
264
+ function writeServerInfo() {
265
+ fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
266
+ fs.writeFileSync(SERVER_FILE, JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: START.dir, pid: process.pid, front: true }, null, 2), { mode: 0o600 });
267
+ fs.chmodSync(SERVER_FILE, 0o600);
268
+ }
269
+ function removeServerInfo() {
270
+ try { if (JSON.parse(fs.readFileSync(SERVER_FILE, "utf8")).token === TOKEN) fs.rmSync(SERVER_FILE, { force: true }); } catch {}
271
+ }
272
+ let listening = false;
273
+ server.on("error", (e) => {
274
+ if (listening) return console.error(e);
275
+ console.error(e.code === "EADDRINUSE"
276
+ ? `Port ${PORT} is in use (another PinDeck, or an SSH tunnel?). Choose another with --port, the same on both ends of a tunnel.`
277
+ : `Cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
278
+ process.exit(1);
279
+ });
280
+ server.listen(PORT, "127.0.0.1", async () => {
281
+ listening = true;
282
+ PORT = server.address().port;
283
+ ORIGIN = `http://127.0.0.1:${PORT}`;
284
+ if (!chooser) {
285
+ try { await openDeck({ key: START.key, dir: START.dir, file: START.file }, true); }
286
+ catch (e) {
287
+ console.error(e.message.replace(/\n+$/, ""));
288
+ process.exit(e.exitCode); // 2 for a bad argument, as the deck server says
289
+ }
290
+ }
291
+ writeServerInfo();
292
+ console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
293
+ console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
294
+ console.log(chooser ? `Decks: ${START.dir}` : `Deck: ${START.dir}`);
295
+ console.log(`Server: ${CAPS.label}`);
296
+ for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
297
+ if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
298
+ });
299
+
300
+ /* Ctrl+C or SIGTERM: every deck ends as it does on its own (its assistant gets end of input; at most 6 s),
301
+ then the front. A second Ctrl+C ends them at once. */
302
+ async function quit() {
303
+ if (quitting) {
304
+ for (const d of decks.values()) try { process.kill(-d.proc.pid, "SIGKILL"); } catch {}
305
+ process.exit(1);
306
+ }
307
+ quitting = true;
308
+ server.close();
309
+ server.closeAllConnections?.();
310
+ removeServerInfo();
311
+ const all = [...decks.values()];
312
+ all.forEach(stopDeck);
313
+ await Promise.race([Promise.all(all.map((d) => d.exited)), new Promise((r) => setTimeout(r, 7000))]);
314
+ for (const d of all) if (d.proc.exitCode === null && d.proc.signalCode === null) try { process.kill(-d.proc.pid, "SIGKILL"); } catch {}
315
+ process.exit(0);
316
+ }
317
+ process.on("SIGINT", quit);
318
+ process.on("SIGTERM", quit);
@@ -0,0 +1,49 @@
1
+ /* The decks the front offers in the editor's deck menu: .html files in the start folder and the asset roots,
2
+ and in their folders one level down. Reading only: a deck gets its element ids (and PinDeck writes its
3
+ state) only once it is opened. A file PinDeck cannot open is listed with the reason, as at start-up. */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { deckTarget } from "./deck-target.mjs";
8
+ import { unsupportedMessage } from "./frameworks.mjs";
9
+
10
+ const SKIP = new Set(["node_modules", "export"]); // dependencies, and PinDeck's own PDF exports
11
+ const MAX_FILES = 300;
12
+ const MAX_BYTES = 5 * 1024 * 1024;
13
+
14
+ const htmlFiles = (dir) => {
15
+ try {
16
+ return fs.readdirSync(dir, { withFileTypes: true })
17
+ .filter((e) => e.isFile() && /\.html?$/i.test(e.name) && !e.name.startsWith(".")).map((e) => e.name).sort();
18
+ } catch { return []; }
19
+ };
20
+ const subfolders = (dir) => {
21
+ try {
22
+ return fs.readdirSync(dir, { withFileTypes: true })
23
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP.has(e.name)).map((e) => e.name).sort();
24
+ } catch { return []; }
25
+ };
26
+
27
+ /* [{ key, dir, file, rel, root, problem }]: rel is the path shown in the menu, relative to its root;
28
+ problem is null for a deck PinDeck can open. A file under several roots is listed once, under the first. */
29
+ export function listDecks(roots) {
30
+ const out = [], seen = new Set();
31
+ for (const root of roots) {
32
+ for (const dir of [root, ...subfolders(root).map((d) => path.join(root, d))]) {
33
+ for (const file of htmlFiles(dir)) {
34
+ const abs = path.join(dir, file);
35
+ if (seen.has(abs) || out.length >= MAX_FILES) continue;
36
+ seen.add(abs);
37
+ let problem = null;
38
+ try {
39
+ const size = fs.statSync(abs).size;
40
+ problem = size > MAX_BYTES ? `${file} is larger than 5 MB, too large for a deck.`
41
+ : unsupportedMessage(fs.readFileSync(abs, "utf8"), file)?.replace(/ Nothing was changed\.$/, "") || null;
42
+ } catch (e) { problem = `${file} cannot be read (${e.code || e.message}).`; }
43
+ const t = deckTarget(file === "index.html" ? dir : abs);
44
+ out.push({ key: t.key, dir, file, rel: path.relative(root, abs).split(path.sep).join("/"), root, problem });
45
+ }
46
+ }
47
+ }
48
+ return out;
49
+ }
@@ -0,0 +1,25 @@
1
+ /* Which deck a path names, and where PinDeck keeps that deck's state: shared by the front (pindeck), which
2
+ lists decks and addresses them as /d/<key>/, and the deck server, which keeps its chat and undo there. */
3
+
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import fs from "node:fs";
7
+ import crypto from "node:crypto";
8
+
9
+ export const homeDir = (env = process.env) => env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
10
+
11
+ /* A file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm that is
12
+ not a folder is a file, so a missing file gets a clear message. */
13
+ export function deckTarget(target) {
14
+ const abs = path.resolve(target);
15
+ const isFolder = (() => { try { return fs.statSync(abs).isDirectory(); } catch { return false; } })();
16
+ const dir = isFolder || !/\.html?$/i.test(abs) ? abs : path.dirname(abs);
17
+ const file = dir === abs ? "index.html" : path.basename(abs);
18
+ const named = file !== "index.html"; // a deck file with its own name; several can share a folder
19
+ const name = named ? file.replace(/\.html?$/i, "") : path.basename(dir); // for exports and titles
20
+ /* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
21
+ histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
22
+ const key = (named ? `${path.basename(dir)}-${name}` : path.basename(dir)).replace(/[^\w.-]+/g, "_") + "-" +
23
+ crypto.createHash("sha1").update(named ? path.join(dir, file) : dir).digest("hex").slice(0, 8);
24
+ return { dir, file, named, name, key };
25
+ }
package/lib/login.mjs ADDED
@@ -0,0 +1,61 @@
1
+ /* Who may read the editor, the deck and the API: shared by the front (pindeck) and each deck server.
2
+
3
+ The token never appears in a page or on a command line, where other users of a shared server could read
4
+ it (ps, curl). A browser logs in once with a one-time code (printed at start, or by `pindeck login`) and
5
+ then holds the token in an HttpOnly cookie; the CLI, Claude and the front send it in a header. Browsers
6
+ share cookies across all ports of a host, so the cookie name carries the server's port. */
7
+
8
+ import crypto from "node:crypto";
9
+
10
+ /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
11
+ Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
12
+ export const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
13
+
14
+ export const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
15
+ <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
16
+ <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
17
+ for a new one, run <code>pindeck login</code> in the deck folder (or <code>node front.mjs login</code> in a
18
+ PinDeck checkout).</p>`;
19
+
20
+ export function cookie(req, name) {
21
+ for (const part of String(req.headers.cookie || "").split(";")) {
22
+ const i = part.indexOf("=");
23
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /* port() and origin() are read on use: --port 0 learns the port only once listening */
29
+ export function createLogin({ token, port, origin }) {
30
+ const loginCodes = new Map(); // code -> expiry (ms)
31
+ function loginCode(ttl) {
32
+ const now = Date.now();
33
+ for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
34
+ const code = crypto.randomBytes(16).toString("hex");
35
+ loginCodes.set(code, now + ttl);
36
+ return code;
37
+ }
38
+ const loginUrl = (code, next = "/") => `${origin()}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
39
+ const cookieName = () => `pindeck-${port()}`;
40
+ const sameSecret = (a) => typeof a === "string" && a.length === token.length &&
41
+ crypto.timingSafeEqual(Buffer.from(a), Buffer.from(token));
42
+ const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
43
+ /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
44
+ start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
45
+ function login(res, url) {
46
+ const code = url.searchParams.get("code") || "";
47
+ const exp = loginCodes.get(code);
48
+ loginCodes.delete(code);
49
+ if (!exp || exp < Date.now()) {
50
+ res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
51
+ return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
52
+ }
53
+ let next = url.searchParams.get("next") || "/";
54
+ if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
55
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
56
+ "set-cookie": `${cookieName()}=${token}; HttpOnly; SameSite=Strict; Path=/` });
57
+ const to = JSON.stringify(next).replace(/</g, "\\u003c");
58
+ res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
59
+ }
60
+ return { loginCode, loginUrl, cookieName, authed, login };
61
+ }
package/lib/usage.mjs ADDED
@@ -0,0 +1,15 @@
1
+ /* pindeck --help, and the hint after a bad argument: the front (front.mjs) and a deck server (server.mjs)
2
+ take the same arguments. */
3
+ export const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
4
+ pindeck login [deck-folder | deck-file.html]
5
+
6
+ Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
7
+ folder), which then opens its index.html; a folder without one opens a list of its decks. A deck made
8
+ otherwise names its slides in its <head> with <meta name="pindeck:slides" content="<CSS selector>"> and
9
+ <meta name="pindeck:size" content="1440x1080">.
10
+ In the editor, the deck's name opens the other decks in the deck's folder and the asset roots (and one
11
+ folder down), each with its own chat and undo, on the same port and login.
12
+ login prints a new one-time login link for the running PinDeck (with a deck: one that opens that deck).
13
+ --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
14
+ ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
15
+ "assetRoots" in ~/.pindeck-v3/config.json.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "3.0.0-beta.3",
3
+ "version": "3.1.0-beta.1",
4
4
  "description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
5
5
  "keywords": [
6
6
  "slides",
@@ -23,9 +23,10 @@
23
23
  "bugs": "https://github.com/ehabets/PinDeck/issues",
24
24
  "type": "module",
25
25
  "bin": {
26
- "pindeck": "server.mjs"
26
+ "pindeck": "front.mjs"
27
27
  },
28
28
  "files": [
29
+ "front.mjs",
29
30
  "server.mjs",
30
31
  "cli.mjs",
31
32
  "lib/",
@@ -34,7 +35,7 @@
34
35
  "LICENSE"
35
36
  ],
36
37
  "scripts": {
37
- "start": "node server.mjs",
38
+ "start": "node front.mjs",
38
39
  "test": "node --test"
39
40
  },
40
41
  "os": [
package/public/editor.css CHANGED
@@ -59,12 +59,28 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
59
59
  min-height: 44px; padding: 5px 12px; background: var(--panel); border-bottom: 1px solid var(--line); flex: none; }
60
60
  .toolbar .group { display: flex; align-items: center; gap: 6px; min-width: 0; flex: none; }
61
61
  .toolbar .group:first-child { flex: 1 1 auto; }
62
- .toolbar .group:first-child > :not(.deck-name) { flex: none; }
62
+ .toolbar .group:first-child > :not(.deck-wrap) { flex: none; }
63
63
  @container (max-width: 640px) {
64
- .toolbar kbd, .toolbar .deck-name { display: none; }
64
+ .toolbar kbd, .toolbar .deck-wrap { display: none; }
65
65
  }
66
66
  .pos { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 52px; text-align: center; }
67
- .deck-name { min-width: 0; margin-left: 8px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
67
+ /* the deck's name; behind the front (several decks on one port) it opens the deck menu */
68
+ .deck-wrap { min-width: 0; margin-left: 2px; }
69
+ .deck-btn { display: flex; align-items: center; gap: 4px; max-width: 100%; min-width: 0; padding: 2px 6px;
70
+ border: 0; background: none; color: inherit; font: inherit; }
71
+ .deck-btn:hover { background: none; }
72
+ .deck-btn:disabled { opacity: 1; cursor: default; }
73
+ .deck-btn:not(:disabled):hover, .deck-btn[aria-expanded="true"] { background: var(--bg); }
74
+ .deck-btn .caret { color: var(--muted); font-size: 11px; }
75
+ .deck-name, #deck-name { min-width: 0; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
76
+ .menu.deck-menu { width: min(360px, 90vw); max-height: min(520px, 70vh); overflow-y: auto; }
77
+ .deck-menu a.opt { display: flex; align-items: baseline; gap: 8px; padding: 6px 10px; border-radius: 7px; color: inherit; text-decoration: none; }
78
+ .deck-menu a.opt:hover, .deck-menu a.opt:focus-visible { background: var(--bg); }
79
+ .deck-menu .opt b { font-weight: 500; overflow-wrap: anywhere; }
80
+ .deck-menu .opt .d { margin-left: auto; color: var(--muted); font-size: 12px; flex: none; }
81
+ .deck-menu button.opt { align-items: flex-start; }
82
+ .deck-menu button.opt span.why { display: block; font-size: 12px; }
83
+ .deck-menu .menu-note { padding: 8px 10px 4px; margin-top: 4px; border-top: 1px solid var(--line); font-size: 12px; color: var(--muted); }
68
84
  .server-note { flex: none; margin-left: 8px; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: #6b4a12; background: #fbf0dc; cursor: help; }
69
85
  .toggle { display: inline-flex; align-items: center; gap: 6px; }
70
86
  .toggle .dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; background: var(--orange); color: #fff;
@@ -202,6 +218,10 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
202
218
  border-radius: 12px; padding: 1px 4px 1px 2px; font-size: 12px; margin: 0 4px 4px 0; cursor: pointer; }
203
219
  .chip .n { width: 18px; height: 18px; border-radius: 50%; background: var(--orange); color: #fff; font-weight: 700;
204
220
  font-size: 11px; line-height: 18px; text-align: center; }
221
+ .shot-chip { font: inherit; color: inherit; }
222
+ .shot-chip img { height: 18px; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: var(--line); }
223
+ .shot-chip.gone { color: var(--muted); cursor: default; padding: 1px 6px; }
224
+ .msg.tool .shot-chip { margin: 0; vertical-align: middle; }
205
225
  .chip .x { border: 0; background: none; padding: 0 2px; color: var(--muted); font-size: 14px; line-height: 1; }
206
226
  .chip .x:hover { color: var(--danger); }
207
227
  .chip .lock { color: var(--muted); }
@@ -270,6 +290,7 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
270
290
  .diff-dialog::backdrop { background: rgba(20,22,25,.35); }
271
291
  .dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
272
292
  border-bottom: 1px solid var(--line); font-weight: 600; }
293
+ .shot-dialog img { display: block; max-width: 100%; max-height: calc(86vh - 50px); margin: 0 auto; }
273
294
  .diff { margin: 0; padding: 10px 0; overflow: auto; max-height: calc(86vh - 50px); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
274
295
  .diff span { display: block; padding: 0 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
275
296
  .diff .a { background: #e9f6ee; }
package/public/editor.js CHANGED
@@ -1,6 +1,11 @@
1
1
  /* PinDeck UI: slide list, canvas with pins, chat with the selected assistant. */
2
2
 
3
3
  const $ = (id) => document.getElementById(id);
4
+ /* The editor's own folder: / when it runs alone, /d/<deck>/ behind the front (pindeck), which serves several
5
+ decks on one port. Every URL of this deck's server starts here. */
6
+ const BASE = new URL(".", location.href).pathname;
7
+ const FRONT = BASE.startsWith("/d/");
8
+ const SLIDE_KEY = FRONT ? `editor.slide:${BASE}` : "editor.slide"; // decks share the origin, not their slide
4
9
  const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
5
10
  /* Changes to the deck carry the revision of the files this tab shows (state.rev); the server refuses them
6
11
  (409, code "stale") if the files have changed since. A change that went through gives the new revision. */
@@ -9,7 +14,7 @@ const CHANGES = new Set(["edit", "text", "split", "remove", "notes", "slide-hidd
9
14
  const api = async (p, body) => {
10
15
  const change = body !== undefined && CHANGES.has(p.split("?")[0]);
11
16
  if (change && state.rev) body = { ...body, rev: state.rev };
12
- const res = await fetch("/api/" + p, {
17
+ const res = await fetch(BASE + "api/" + p, {
13
18
  method: body === undefined ? "GET" : "POST",
14
19
  headers: { "content-type": "application/json" },
15
20
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -18,7 +23,7 @@ const api = async (p, body) => {
18
23
  return res;
19
24
  };
20
25
 
21
- const savedSlide = (() => { try { return +localStorage.getItem("editor.slide") || 0; } catch { return 0; } })();
26
+ const savedSlide = (() => { try { return +localStorage.getItem(SLIDE_KEY) || 0; } catch { return 0; } })();
22
27
  const state = {
23
28
  status: {}, slides: [], current: savedSlide, pins: [], mode: "select", sel: null, // mode: "select" (move, resize) or "pin"
24
29
  busy: false, status: {}, turns: new Map(), // turnId -> { undone }
@@ -31,8 +36,8 @@ let measure = null; // { iframe, bridge }
31
36
  let measureReady = null, measureResolve = null;
32
37
 
33
38
  // showHiddenSlides: PinDeck shows hidden slides so they can be edited (reveal.js drops them otherwise)
34
- // /deck/ is the deck file the server was started with (index.html, or a named file such as talk.html)
35
- function deckSrc(mode, extra = "") { return `/deck/?editor=${mode}&showHiddenSlides=true${extra}`; }
39
+ // deck/ is the deck file the server was started with (index.html, or a named file such as talk.html)
40
+ function deckSrc(mode, extra = "") { return `${BASE}deck/?editor=${mode}&showHiddenSlides=true${extra}`; }
36
41
 
37
42
  function loadMain() {
38
43
  const f = document.createElement("iframe");
@@ -83,7 +88,7 @@ window.__editorHost = {
83
88
  },
84
89
  slideChanged(index) {
85
90
  state.current = index;
86
- try { localStorage.setItem("editor.slide", String(index)); } catch {}
91
+ try { localStorage.setItem(SLIDE_KEY, String(index)); } catch {}
87
92
  renderSlides();
88
93
  renderToolbar();
89
94
  loadNotes();
@@ -297,7 +302,7 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
297
302
  if (!res.ok) return toast(d.error || "Could not move the slide.");
298
303
  if (d.unchanged) return;
299
304
  state.current = d.index; // the reload opens the moved slide
300
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
305
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
301
306
  loadMain();
302
307
  }
303
308
 
@@ -366,7 +371,7 @@ notesBox.addEventListener("keydown", (e) => {
366
371
  // closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
367
372
  addEventListener("pagehide", () => {
368
373
  if (notesDirty() && !state.busy)
369
- navigator.sendBeacon("/api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
374
+ navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
370
375
  });
371
376
  function setNotesOpen(open) {
372
377
  $("notes-panel").classList.toggle("collapsed", !open);
@@ -465,6 +470,31 @@ function wireMenu(btnId, menuId, beforeOpen) {
465
470
  const closeMenus = () => setMenu(null, null, false);
466
471
  document.addEventListener("click", (e) => { if (!e.target.closest(".menu")) closeMenus(); });
467
472
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(); }, true);
473
+ /* The deck menu, behind the front only: the decks in the deck's folder and the asset roots (and one folder
474
+ down). Each is a link, so a deck opens in place, or in a new tab with ⌘-click; the deck left behind keeps
475
+ its chat and undo, and the assistant goes on with a turn it has begun. */
476
+ if (FRONT) {
477
+ $("deck-btn").disabled = false;
478
+ $("deck-btn").title = "Open another deck";
479
+ $("deck-btn").querySelector(".caret").hidden = false;
480
+ wireMenu("deck-btn", "deck-menu", async () => {
481
+ const menu = $("deck-menu");
482
+ menu.innerHTML = '<div class="menu-note">Looking for decks…</div>';
483
+ let decks = [];
484
+ try { decks = (await (await fetch("/api/decks")).json()).decks; }
485
+ catch { menu.innerHTML = '<div class="menu-note">The deck list is not available.</div>'; return; }
486
+ const roots = [...new Set(decks.map((d) => d.root))];
487
+ menu.innerHTML = roots.map((root) => (roots.length > 1 ? `<div class="group" title="${esc(root)}">${esc(root.split("/").pop())}</div>` : "") +
488
+ decks.filter((d) => d.root === root).map((d) => {
489
+ const here = BASE === `/d/${d.key}/`;
490
+ return d.problem
491
+ ? `<button class="opt" role="menuitem" disabled title="${esc(d.problem)}"><span class="tick"></span><span><b>${esc(d.rel)}</b><span class="why">${esc(d.problem.replace(/^\S+ /, "").split(". ")[0])}.</span></span></button>`
492
+ : `<a class="opt" role="menuitem" href="/d/${esc(d.key)}/" ${here ? 'aria-current="page"' : ""}><span class="tick">${here ? "✓" : ""}</span>` +
493
+ `<b>${esc(d.rel)}</b>${here ? "" : d.open ? '<span class="d">open</span>' : ""}</a>`;
494
+ }).join("")).join("") +
495
+ '<div class="menu-note">A deck opens here (⌘-click: in a new tab), with its own chat and undo. The assistant finishes its turn in the deck you leave.</div>';
496
+ });
497
+ }
468
498
  const setExportMenu = (open) => setMenu($("export-btn"), $("export-menu"), open);
469
499
  wireMenu("export-btn", "export-menu");
470
500
  $("export-menu").addEventListener("click", async (e) => {
@@ -493,7 +523,7 @@ $("export-menu").addEventListener("click", async (e) => {
493
523
  });
494
524
 
495
525
  $("present").onclick = () => {
496
- window.open(`/deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
526
+ window.open(`${BASE}deck/${main?.bridge.presentHash(state.current) || ""}`, "_blank");
497
527
  };
498
528
 
499
529
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -571,17 +601,21 @@ function md(s) {
571
601
  }).join("");
572
602
  return out.replace(/<p>\u0000(\d+)\u0000<\/p>|\u0000(\d+)\u0000/g, (_, a, b) => `<pre>${blocks[a ?? b]}</pre>`);
573
603
  }
604
+ /* A screenshot in the chat: a small picture that opens large. Only the newest ones are kept on disk;
605
+ an older one says so (see the error listener below). */
606
+ const shotChip = (file, label = "") => `<button type="button" class="chip shot-chip" data-shot="${esc(file)}" title="Open the screenshot">` +
607
+ `<img src="${BASE}shots/${encodeURIComponent(file)}" alt="" loading="lazy">${label ? ` ${esc(label)}` : ""}</button>`;
574
608
  function entryHtml(e) {
575
609
  switch (e.kind) {
576
610
  case "user":
577
611
  return `${esc(e.text)}${e.pins?.length || e.screenshot || e.selection || e.attachments?.length ? `<div class="chips">${e.selection ?
578
612
  `<span class="chip sel" title="${esc(e.selection.eid)}"><span class="n">▢</span> S${e.selection.slide} · ${esc(e.selection.label)}</span>` : ""}${(e.pins || []).map((p) =>
579
613
  `<span class="chip" title="${esc(p.eid)}"><span class="n">${p.n}</span> S${p.slide} · ${esc(p.label || p.eid)}</span>`).join("")}${
580
- e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
581
- `<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="/deck/${esc(a.path)}" alt="">` : a.kind === "pdf" ? "📄" : "🔊"} ${esc(a.path.split("/").pop())}</span>`).join("")}</div>` : ""}`;
614
+ e.screenshot?.file ? shotChip(e.screenshot.file, `screenshot S${e.screenshot.slide}`) : e.screenshot ? '<span class="chip">📷 screenshot</span>' : ""}${(e.attachments || []).map((a) =>
615
+ `<span class="chip att-chip" title="${esc(a.path)}">${a.kind === "image" ? `<img src="${BASE}deck/${esc(a.path)}" alt="">` : a.kind === "pdf" ? "📄" : "🔊"} ${esc(a.path.split("/").pop())}</span>`).join("")}</div>` : ""}`;
582
616
  case "assistant": return md(e.text);
583
617
  case "tool":
584
- return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
618
+ return `<span><span class="tn">${esc(e.name)}</span> ${esc(e.detail || "")}${e.shot ? " " + shotChip(e.shot) : ""}${e.error ? `<span class="err">${esc(e.error)}</span>` : ""}</span>`;
585
619
  case "edit": {
586
620
  const t = state.turns.get(e.turnId);
587
621
  return `<span class="files ${t?.undone ? "undone" : ""}">✎ ${esc(e.label)}</span>
@@ -629,7 +663,23 @@ function clearChat() {
629
663
  $("chat-empty").hidden = false;
630
664
  }
631
665
 
666
+ // a picture that is no longer kept: the chip says so and opens nothing (errors do not bubble: capture)
667
+ chatEl.addEventListener("error", (e) => {
668
+ const chip = e.target.closest?.("[data-shot]");
669
+ if (!chip || e.target.tagName !== "IMG") return;
670
+ chip.removeAttribute("data-shot");
671
+ chip.classList.add("gone");
672
+ chip.title = "Only the newest screenshots are kept";
673
+ chip.textContent = "📷 no longer kept";
674
+ }, true);
632
675
  chatEl.addEventListener("click", async (e) => {
676
+ const s = e.target.closest("[data-shot]");
677
+ if (s) {
678
+ $("shot-img").src = `${BASE}shots/${encodeURIComponent(s.dataset.shot)}`;
679
+ $("shot-title").textContent = s.dataset.shot;
680
+ $("shot-dialog").showModal();
681
+ return;
682
+ }
633
683
  const d = e.target.closest("[data-diff]");
634
684
  if (d) {
635
685
  const res = await api(`turn/${d.dataset.diff}/diff`);
@@ -651,6 +701,9 @@ chatEl.addEventListener("click", async (e) => {
651
701
  }
652
702
  });
653
703
  $("diff-close").onclick = () => $("diff-dialog").close();
704
+ $("shot-close").onclick = () => $("shot-dialog").close();
705
+ // Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
706
+ $("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
654
707
 
655
708
  function setBusy(busy) {
656
709
  state.busy = busy;
@@ -747,7 +800,7 @@ function applyStatus(s) {
747
800
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
748
801
  const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
749
802
  $("deck-name").textContent = name;
750
- $("deck-name").title = `${s.deck}/${s.deckFile || "index.html"}`;
803
+ $("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
751
804
  if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
752
805
  document.title = `${name} · PinDeck`;
753
806
  }
@@ -792,7 +845,7 @@ function applyStatus(s) {
792
845
  renderToolbar();
793
846
  }
794
847
  function connect() {
795
- const es = new EventSource("/api/events");
848
+ const es = new EventSource(BASE + "api/events");
796
849
  es.addEventListener("hello", (ev) => {
797
850
  const d = JSON.parse(ev.data);
798
851
  // reconnected after missing file events (tunnel dropped, laptop asleep): show the files as they are now
@@ -868,7 +921,7 @@ $("add-slide-menu").addEventListener("click", async (e) => {
868
921
  if (!res.ok) return toast(d.error || "Could not add the slide.");
869
922
  state.current = d.index; // the reload opens the new slide ...
870
923
  state.pendingTitleEdit = item.dataset.slide !== "duplicate"; // ... with its title ready for typing
871
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
924
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
872
925
  loadMain();
873
926
  });
874
927
 
@@ -881,7 +934,7 @@ $("import-file").addEventListener("change", async () => {
881
934
  $("import-file").value = "";
882
935
  if (!file) return;
883
936
  toast(`Reading ${file.name}…`);
884
- const res = await fetch("/api/import", { method: "POST", body: file,
937
+ const res = await fetch(BASE + "api/import", { method: "POST", body: file,
885
938
  headers: { "x-filename": encodeURIComponent(file.name), "content-type": "application/octet-stream" } });
886
939
  const d = await res.json().catch(() => ({}));
887
940
  if (!res.ok) return toast(d.error || `Could not read ${file.name}.`);
@@ -925,7 +978,7 @@ function showImportThumbs(ev) {
925
978
  for (const c of importChecks()) {
926
979
  const img = new Image();
927
980
  img.alt = "";
928
- img.src = `/api/import-thumb?id=${importing.id}&n=${c.value}`;
981
+ img.src = `${BASE}api/import-thumb?id=${importing.id}&n=${c.value}`;
929
982
  c.closest(".thumb").append(img);
930
983
  }
931
984
  }
@@ -943,7 +996,7 @@ $("import-dialog").addEventListener("close", async () => {
943
996
  toast(`Imported ${d.count} slide${d.count > 1 ? "s" : ""}.` + (d.missing ? ` ${d.missing} diagram${d.missing > 1 ? "s" : ""} could not be pictured (no PowerPoint or Keynote).` : "") +
944
997
  (d.refining ? ` The assistant is improving ${d.refining === 1 ? "the complex slide" : `${d.refining} complex slides`}.` : ""), { ms: 5000 });
945
998
  state.current = d.index;
946
- try { localStorage.setItem("editor.slide", String(d.index)); } catch {}
999
+ try { localStorage.setItem(SLIDE_KEY, String(d.index)); } catch {}
947
1000
  loadMain();
948
1001
  });
949
1002
 
@@ -1090,7 +1143,7 @@ const kindOf = (f) => (f.type.startsWith("image/") || IMAGE_EXT.test(f.name) ? "
1090
1143
  const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} kB`);
1091
1144
 
1092
1145
  async function uploadBlob(blob, name) {
1093
- const res = await fetch("/api/upload", { method: "POST", body: blob,
1146
+ const res = await fetch(BASE + "api/upload", { method: "POST", body: blob,
1094
1147
  headers: { "x-filename": encodeURIComponent(name), "content-type": blob.type || "application/octet-stream" } });
1095
1148
  const d = await res.json().catch(() => ({}));
1096
1149
  if (!res.ok) throw new Error(d.error || `Could not upload ${name}.`);
@@ -1178,7 +1231,7 @@ function renderAttachments() {
1178
1231
  a.pages ? `${a.pages} page${a.pages > 1 ? "s" : ""}` : "",
1179
1232
  fmtBytes(a.bytes), a.droppedAt ? `dropped on slide ${a.droppedAt.slide.index + 1}` : ""].filter(Boolean).join(" · ");
1180
1233
  return `<span class="att ${a.status}" title="${esc(a.path || a.name)}">
1181
- ${a.kind === "image" ? `<img class="thumb" src="${a.preview || "/deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
1234
+ ${a.kind === "image" ? `<img class="thumb" src="${a.preview || BASE + "deck/" + esc(a.path)}" alt="">` : `<span class="icon-box">${a.kind === "pdf" ? "📄" : "🔊"}</span>`}
1182
1235
  <span class="meta"><b>${esc(a.path ? a.path.split("/").pop() : a.name)}</b><span>${esc(info)}</span></span>
1183
1236
  ${a.status === "uploading" ? '<span class="spinner"></span>' : ""}
1184
1237
  <button class="x" data-unattach="${a.id}" aria-label="Remove ${esc(a.name)}">×</button></span>`;
@@ -0,0 +1,14 @@
1
+ /* The front's own pages: the list of decks (a folder without index.html) and why a deck cannot be opened */
2
+ body { margin: 0; background: #f6f6f4; color: #1d1f22; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
3
+ .front { max-width: 44em; margin: 3em auto; padding: 0 16px; }
4
+ .front h1 { font-size: 22px; margin: 0 0 .8em; }
5
+ .front h2 { font-size: 12px; font-weight: 600; color: #6b6f76; margin: 1.6em 0 .4em; overflow-wrap: anywhere; }
6
+ .front ul { list-style: none; margin: 0; padding: 0; border: 1px solid #e3e3df; border-radius: 10px; background: #fff; }
7
+ .front li { padding: 9px 14px; border-top: 1px solid #ececea; }
8
+ .front li:first-child { border-top: 0; }
9
+ .front li a { color: #1d1f22; font-weight: 500; text-decoration: none; }
10
+ .front li a:hover { color: #e8710a; }
11
+ .front li small { color: #6b6f76; margin-left: 6px; }
12
+ .front li.off { color: #8a8e94; }
13
+ .front li.off small { display: block; margin: 2px 0 0; font-size: 12px; }
14
+ .front pre { white-space: pre-wrap; background: #fff; border: 1px solid #e3e3df; border-radius: 8px; padding: 10px 12px; font-size: 13px; }
package/public/index.html CHANGED
@@ -35,7 +35,10 @@
35
35
  <button id="prev" class="icon" title="Previous slide (←)" aria-label="Previous slide">‹</button>
36
36
  <span id="slide-pos" class="pos">– / –</span>
37
37
  <button id="next" class="icon" title="Next slide (→)" aria-label="Next slide">›</button>
38
- <span id="deck-name" class="deck-name"></span>
38
+ <span class="menu-wrap deck-wrap">
39
+ <button id="deck-btn" class="deck-btn" aria-haspopup="menu" aria-expanded="false" disabled><span id="deck-name"></span><span class="caret" hidden>▾</span></button>
40
+ <div id="deck-menu" class="menu left deck-menu" role="menu" hidden></div>
41
+ </span>
39
42
  <span id="server-note" class="server-note" hidden></span>
40
43
  </div>
41
44
  <div class="group">
@@ -172,6 +175,11 @@
172
175
  </form>
173
176
  </dialog>
174
177
 
178
+ <dialog id="shot-dialog" class="diff-dialog shot-dialog">
179
+ <div class="dialog-head"><span id="shot-title">Screenshot</span><button class="small" id="shot-close">Close</button></div>
180
+ <img id="shot-img" alt="Screenshot">
181
+ </dialog>
182
+
175
183
  <dialog id="diff-dialog" class="diff-dialog">
176
184
  <div class="dialog-head"><span id="diff-title">Changes</span><button class="small" id="diff-close">Close</button></div>
177
185
  <pre id="diff-body" class="diff"></pre>
package/server.mjs CHANGED
@@ -39,19 +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
- const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
45
- pindeck login [deck-folder | deck-file.html]
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. A deck made otherwise names its slides in its <head> with
49
- <meta name="pindeck:slides" content="<CSS selector>"> and <meta name="pindeck:size" content="1440x1080">.
50
- login prints a new one-time login link for the PinDeck running for that deck
51
- (without a deck: for the PinDeck started last).
52
- --asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
53
- ../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
54
- "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;
55
50
  const EDITOR_DIR = path.dirname(fileURLToPath(import.meta.url));
56
51
  const argv = process.argv.slice(2);
57
52
  if (argv.includes("--help") || argv.includes("-h")) { console.log(USAGE); process.exit(0); }
@@ -70,23 +65,14 @@ const LOGIN = argv[0] === "login";
70
65
  if (LOGIN) argv.shift();
71
66
  const PROVIDER = opt("--provider", "");
72
67
  const MODEL = opt("--model", "");
73
- /* The deck: a file (talk.html), or a folder, which means its index.html. A path ending in .html or .htm
74
- that is not a folder is a file, so a missing file gets a clear message. */
75
- const TARGET = path.resolve(argv[0] || process.cwd());
76
- const isFolder = (() => { try { return fs.statSync(TARGET).isDirectory(); } catch { return false; } })();
77
- const DECK_DIR = isFolder || !/\.html?$/i.test(TARGET) ? TARGET : path.dirname(TARGET);
78
- const DECK_FILE = DECK_DIR === TARGET ? "index.html" : path.basename(TARGET);
79
- const NAMED = DECK_FILE !== "index.html"; // a deck file with its own name; several can share a folder
80
- 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());
81
70
  const EDITABLE = [DECK_FILE, "theme.css"]; // both backends may edit these
82
71
  const LOCKED = ["brand.css"]; // never, enforced twice
83
- const HOME_DIR = process.env.PINDECK_HOME || path.join(os.homedir(), ".pindeck-v3");
84
- /* State per deck file. A folder's index.html keeps the key it always had, so existing chats and undo
85
- histories stay; a named file gets its own, so two decks in one folder never share chat or undo. */
86
- const deckKey = (NAMED ? `${path.basename(DECK_DIR)}-${DECK_NAME}` : path.basename(DECK_DIR)).replace(/[^\w.-]+/g, "_") + "-" +
87
- crypto.createHash("sha1").update(NAMED ? path.join(DECK_DIR, DECK_FILE) : DECK_DIR).digest("hex").slice(0, 8);
72
+ const HOME_DIR = homeDir();
88
73
  const STATE_DIR = path.join(HOME_DIR, "decks", deckKey);
89
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
90
76
  const PDF_DIR = path.join(STATE_DIR, "attachments"); // attached PDFs: reading material, not part of the deck
91
77
  /* pindeck login: a new one-time login link from the running server, asked with the token in its server
92
78
  file (readable only by this user) */
@@ -136,7 +122,9 @@ if (frameworkProblem) {
136
122
  }
137
123
  /* Runtime files hold the token, the chat and attachments: private to the user. Written only after the
138
124
  port is bound (see Start), so a failed start leaves no state behind. */
139
- const SERVER_FILES = [path.join(STATE_DIR, "server.json"), path.join(HOME_DIR, "server.json")]; // the second: the CLI's fallback
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")];
140
128
  function writeServerInfo() {
141
129
  for (const d of [HOME_DIR, STATE_DIR, SHOT_DIR]) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
142
130
  const info = JSON.stringify({ url: ORIGIN, token: TOKEN, deckDir: DECK_DIR, pid: process.pid }, null, 2);
@@ -400,7 +388,7 @@ function exportPdf(kind, includeHidden = false) {
400
388
  try { await printPdf(CHROME, chromeStart(printProfile, `${ORIGIN}${DECK_BASE}__pindeck-print-${id}.html`), out, printProfile); }
401
389
  finally { snapshots.delete(id); }
402
390
  return { kind: split ? "presentation" : "handout", path: out, rel: path.relative(DECK_DIR, out),
403
- url: `/deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
391
+ url: `deck/export/${encodeURIComponent(name)}`, bytes: fs.statSync(out).size };
404
392
  };
405
393
  const job = exportQueue.then(run, run);
406
394
  exportQueue = job.catch(() => {});
@@ -666,11 +654,13 @@ function toolSummary(name, input = {}) {
666
654
  const r = path.relative(DECK_DIR, p);
667
655
  return !r || r.startsWith("..") ? path.basename(p) : r;
668
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) } : {});
669
659
  switch (name) {
670
- case "pindeck_read": return { name: "Read", detail: base(input.path) };
660
+ case "pindeck_read": return { name: "Read", detail: base(input.path), ...shotOf(input.path) };
671
661
  case "pindeck_edit": case "pindeck_write": return { name: "Edit", detail: base(input.path) };
672
662
  case "pindeck_command": return { name: input.command || "Command", detail: (input.args || []).join(" ") };
673
- 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) };
674
664
  case "Edit": case "MultiEdit": case "Write": return { name: "Edit", detail: base(input.file_path) };
675
665
  case "Bash": {
676
666
  // a PinDeck command only when the whole command is one: a script that ends in `cli.mjs layout`
@@ -1161,57 +1151,9 @@ async function refineImport(imp, pdf, plans, eids, first) {
1161
1151
 
1162
1152
  /* ---- HTTP ------------------------------------------------------------------------------------- */
1163
1153
  const BRIDGE_TAG = `<script src="/editor/bridge.js"></script>\n</body>`;
1164
- /* Pages opened as 127.0.0.1 or localhost only, on any port: an SSH tunnel may use another local port.
1165
- Another site's name pointed at 127.0.0.1 (DNS rebinding) must not read the editor page, its token or the deck. */
1166
- const goodHost = (h) => /^(127\.0\.0\.1|localhost)(:\d{1,5})?$/.test(h);
1167
-
1168
- /* Who may read the editor, the deck and the API. The token never appears in a page or on a command line,
1169
- where other users of a shared server could read it (ps, curl). A browser logs in once with a one-time
1170
- code (printed at start, or by `pindeck login`) and then holds the token in an HttpOnly cookie; the CLI
1171
- and Claude send it in a header. Browsers share cookies across all ports of a host, so the cookie name
1172
- carries this server's port. */
1173
- const loginCodes = new Map(); // code -> expiry (ms)
1174
- function loginCode(ttl) {
1175
- const now = Date.now();
1176
- for (const [c, exp] of loginCodes) if (exp < now) loginCodes.delete(c);
1177
- const code = crypto.randomBytes(16).toString("hex");
1178
- loginCodes.set(code, now + ttl);
1179
- return code;
1180
- }
1181
- const loginUrl = (code, next = "/") => `${ORIGIN}/login?` + new URLSearchParams({ code, ...(next !== "/" ? { next } : {}) });
1182
- const cookieName = () => `pindeck-${PORT}`;
1183
- const sameSecret = (a) => typeof a === "string" && a.length === TOKEN.length &&
1184
- crypto.timingSafeEqual(Buffer.from(a), Buffer.from(TOKEN));
1185
- function cookie(req, name) {
1186
- for (const part of String(req.headers.cookie || "").split(";")) {
1187
- const i = part.indexOf("=");
1188
- if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
1189
- }
1190
- return null;
1191
- }
1192
- const authed = (req) => sameSecret(req.headers["x-editor-token"]) || sameSecret(cookie(req, cookieName()));
1193
- const LOGIN_PAGE = `<!doctype html><meta charset="utf-8"><title>PinDeck</title>
1194
- <body style="font:16px system-ui,sans-serif;max-width:36em;margin:4em auto;padding:0 1em">
1195
- <h1>Log in to PinDeck</h1><p>Open the login link that PinDeck printed when it started. Links work once;
1196
- for a new one, run <code>pindeck login</code> in the deck folder (or <code>node server.mjs login</code> in a
1197
- PinDeck checkout).</p>`;
1198
- /* A one-time code: set the cookie, then go on with a same-origin navigation (a redirect from a cross-site
1199
- start, such as a file:// page or a link in a terminal, would not carry a SameSite=Strict cookie). */
1200
- function login(res, url) {
1201
- const code = url.searchParams.get("code") || "";
1202
- const exp = loginCodes.get(code);
1203
- loginCodes.delete(code);
1204
- if (!exp || exp < Date.now()) {
1205
- res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1206
- return res.end(LOGIN_PAGE.replace("<p>", "<p>This login link has expired or was used already. "));
1207
- }
1208
- let next = url.searchParams.get("next") || "/";
1209
- if (!next.startsWith("/") || next.startsWith("//") || next.includes("\\")) next = "/";
1210
- res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
1211
- "set-cookie": `${cookieName()}=${TOKEN}; HttpOnly; SameSite=Strict; Path=/` });
1212
- const to = JSON.stringify(next).replace(/</g, "\\u003c");
1213
- res.end(`<!doctype html><meta charset="utf-8"><title>PinDeck</title><script>location.replace(${to})</script>`);
1214
- }
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 });
1215
1157
  /* Headless Chrome gets a start page in its own profile folder (0700) instead of a URL with a secret on
1216
1158
  its command line: the page sends it to a one-time login code, which leads on to the target. */
1217
1159
  function chromeStart(profile, target) {
@@ -1285,9 +1227,10 @@ const server = http.createServer(async (req, res) => {
1285
1227
  return res.end(html);
1286
1228
  }
1287
1229
  // /deck/: the deck folder; /deck/ alone is the deck page. With an asset root the page lives under
1288
- // /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>/).
1289
1232
  if (req.method === "GET" && p === "/deck/" && SERVE_ROOT) {
1290
- 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" });
1291
1234
  return res.end();
1292
1235
  }
1293
1236
  const deckPath = req.method === "GET" && (p.startsWith("/deck/") ? servable(DECK_DIR, p.slice(6) || DECK_FILE)
@@ -1309,6 +1252,17 @@ const server = http.createServer(async (req, res) => {
1309
1252
  });
1310
1253
  }
1311
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
+
1312
1266
  // API: token required (header or login cookie); POSTs must come from the editor page itself
1313
1267
  if (!p.startsWith("/api/")) return text(res, 404, "Not found");
1314
1268
  if (!ok) return text(res, 403, "Bad token");
@@ -1365,7 +1319,8 @@ const server = http.createServer(async (req, res) => {
1365
1319
  // only files PinDeck itself stored can be attached
1366
1320
  b.attachments = (b.attachments || []).filter((a) => a && state.uploads.has(a.path) && fs.existsSync(uploadFile(a.path)));
1367
1321
  b.attachments.forEach((a) => { state.uploads.delete(a.path); if (a.waveform) state.uploads.delete(a.waveform); });
1368
- const entry = addEntry({ kind: "user", text: b.text, screenshot: !!screenshot,
1322
+ const entry = addEntry({ kind: "user", text: b.text,
1323
+ screenshot: screenshot && { file: path.basename(screenshot.path), slide: screenshot.slide },
1369
1324
  attachments: b.attachments.map((a) => ({ path: a.path, kind: a.kind, name: a.name })),
1370
1325
  selection: b.selection ? { eid: b.selection.eid, label: b.selection.eid.replace(/^[^/]+\//, ""), slide: b.selection.slide.index + 1 } : null,
1371
1326
  pins: (b.pins || []).map((p) => ({ n: p.n, eid: p.eid, label: label(p), slide: p.slide.index + 1 })) });
@@ -1398,7 +1353,7 @@ const server = http.createServer(async (req, res) => {
1398
1353
  const rel = attachPath(original);
1399
1354
  writeDeckFile(rel, buf);
1400
1355
  state.uploads.add(rel);
1401
- return json(res, 200, { path: rel, kind, bytes: buf.length, url: `/deck/${rel}` });
1356
+ return json(res, 200, { path: rel, kind, bytes: buf.length, url: `deck/${rel}` });
1402
1357
  }
1403
1358
  if (p === "/api/upload-remove" && req.method === "POST") {
1404
1359
  const b = await body(req);
@@ -1727,12 +1682,18 @@ server.listen(PORT, "127.0.0.1", () => {
1727
1682
  process.exit(1);
1728
1683
  }
1729
1684
  ready = true;
1730
- console.log(`PinDeck: ${loginUrl(loginCode(3600000))}`);
1731
- console.log(" (a one-time login link, valid for an hour; for a new one: pindeck login)");
1732
- console.log(`Deck: ${DECK_DIR}`);
1733
- console.log(`Server: ${CAPS.label}`);
1734
- for (const [name, c] of Object.entries(publicCapabilities(CAPS)))
1735
- if (c && c.available === false) console.log(` ${name}: off (${c.reason})`);
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
+ }
1736
1697
  discoverModels().then(() => {
1737
1698
  if (!settings.model) {
1738
1699
  const models = catalog[settings.provider].models;