pindeck 3.0.0-beta.2 → 3.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.2`). 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.2 /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.
@@ -101,7 +112,8 @@ errors; PinDeck does not fall back to broader permissions. Recheck backend compa
101
112
 
102
113
  ## Preparing a deck
103
114
 
104
- PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`. Two
115
+ PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`, and with other
116
+ decks that name their slides (see [Decks made without reveal.js](#decks-made-without-revealjs)). Two
105
117
  optional `<meta>` tags in the deck's `<head>` tell it about the design:
106
118
 
107
119
  ```html
@@ -116,6 +128,40 @@ numbers will jump where a slide is hidden.
116
128
  Without them PinDeck runs no footer check and locks only `.slide-foot` and `.logo`. A file called
117
129
  `brand.css` in the deck folder is locked for both backends. See `ocean-example/` for a complete deck.
118
130
 
131
+ ### Decks made without reveal.js
132
+
133
+ A deck with a script of its own, or made with a framework whose slides are elements of their own
134
+ (Shower, deck.js, a hand-written deck), can be edited too once it names its slides in its `<head>`:
135
+
136
+ ```html
137
+ <meta name="pindeck:slides" content="main.deck > section.slide"> <!-- a CSS selector for the slides -->
138
+ <meta name="pindeck:size" content="1440x1080"> <!-- their size in px -->
139
+ ```
140
+
141
+ The selector may use tag names, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by spaces or `>`,
142
+ and a list of them separated by commas. In the editor, PinDeck shows the slides itself, one at a time and
143
+ scaled to the window, and switches off the deck's own scaling, slide keys and inert or hidden marks while
144
+ you edit; that changes nothing in the deck file. Everything in [Working with it](#working-with-it) works as
145
+ with a reveal.js deck, with these differences:
146
+
147
+ * Build steps (`.fragment`) belong to reveal.js: other decks show every element at once.
148
+ * Hidden slides (`data-visibility="hidden"`) are left out of PinDeck's PDF exports; whether presenting
149
+ skips them is up to the deck's own script.
150
+ * Speaker notes are an `<aside class="notes">` in the slide, as in reveal.js. PinDeck hides them in the
151
+ editor; the deck's CSS should hide them too (`aside.notes { display: none }`).
152
+ * **Present** opens the deck at `#<slide id>`, or `#<number>` (counting from 1) for a slide without an id.
153
+ Present may open on the first slide if the deck does not go to slides by their address.
154
+ * **+ New slide** models a new slide on the deck's own (see [Making your own template](#making-your-own-template)):
155
+ a content slide gets placeholder bullets in a `.slide-body`; without one it has only its title. A new or
156
+ duplicated slide does not take the original's `id`.
157
+
158
+ Both tags are needed: PinDeck opens the slides at the given size for screenshots and PDF pages. A missing
159
+ size, or a selector that finds no slides or that PinDeck cannot read, is reported before anything changes.
160
+ While PinDeck runs, it reads the tags again for every screenshot, PDF and assistant session; the editor
161
+ reads them when the slide reloads. If a tag stops working meanwhile, the editor shows the same message and
162
+ the assistant is told. A session already running keeps the slide selector it started with; **New chat**
163
+ starts one with the new selector.
164
+
119
165
  ## Making your own template
120
166
 
121
167
  PinDeck has no template generator; a template is simply a deck folder. Start from a copy of the example:
@@ -207,7 +253,7 @@ The v3 preview defaults to **`~/.pindeck-v3`**, keeping v2 settings and conversa
207
253
  not copy the v2 directory automatically. To run both versions at once, use another port, for example:
208
254
 
209
255
  ```bash
210
- 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
211
257
  ```
212
258
 
213
259
  ## Backend permissions and attachments
@@ -249,7 +295,7 @@ the `claude` or `codex` CLI installed and logged in under your account there, if
249
295
  editing by hand still works. Then, in one SSH session on the server:
250
296
 
251
297
  ```bash
252
- node server.mjs path/to/deck --port 5180
298
+ node front.mjs path/to/deck --port 5180
253
299
  ```
254
300
 
255
301
  and on your Mac, in another terminal (`myserver` is your usual SSH host):
@@ -262,7 +308,7 @@ Open the login link PinDeck printed on the server in the Mac's browser. The loca
262
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
263
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
264
310
  server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
265
- 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
266
312
  path/to/deck` on the server for a new link.
267
313
 
268
314
  Other users of a shared server cannot open the editor or read the deck through PinDeck: the token that
@@ -376,6 +422,10 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
376
422
 
377
423
  ## How it works
378
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.
379
429
  * **Element ids.** On start, the server gives every slide and every visible element a readable
380
430
  `data-eid` attribute (`summary`, `summary/callout`, `summary/li-2`). Only the attribute is
381
431
  inserted; the rest of the file stays as it was. A backup of the file before the first change is
@@ -427,7 +477,9 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
427
477
 
428
478
  | File | Purpose |
429
479
  |---|---|
430
- | `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. |
431
483
  | `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for the backends (and for you), and `git-setup`. |
432
484
  | `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
433
485
  | `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
@@ -448,19 +500,39 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
448
500
 
449
501
  ## Current Limitations
450
502
 
451
- * reveal.js decks only. A deck made with another framework (Marp, Slidev, impress.js, remark, Spectacle,
452
- Shower, deck.js, bespoke.js) is recognised and refused with its name before PinDeck changes anything.
503
+ * reveal.js decks, and decks that name their slides with `<meta name="pindeck:slides">` (see [Decks made
504
+ without reveal.js](#decks-made-without-revealjs)). Any other deck made with another framework (Marp, Slidev,
505
+ impress.js, remark, Spectacle, Shower, deck.js, bespoke.js) is recognised and refused with its name before
506
+ PinDeck changes anything.
453
507
  * Text inside inline SVG diagrams cannot be edited in place; pin it and ask the assistant.
454
508
  * Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
455
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.
456
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.
457
- * 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.
458
513
  * One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
459
514
  * Linux (beta): pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
460
515
  Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server-beta)).
461
516
 
462
517
  ## Versions
463
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
+
530
+ * **3.0.0-beta.3**: everything from 2.10.0, for both assistants.
531
+ - Decks made without reveal.js: a deck that names its slides with `<meta name="pindeck:slides">`
532
+ and their size with `<meta name="pindeck:size">` is edited like a reveal.js deck (contributed by
533
+ Zeyu Xu). Claude and Codex are both told what the deck's slides are.
534
+ - Screenshots fill the picture instead of being letterboxed into a shorter page.
535
+
464
536
  * **3.0.0-beta.2**: everything from 2.9 and 2.10, with Codex brought up to it.
465
537
  - Security: the fix from 2.9. The editor page no longer contains the token, and the deck needs it
466
538
  too, so other users of the same machine cannot read either. PinDeck prints a one-time login link at
@@ -480,8 +552,8 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
480
552
  switching with optional handover, curated model lists, a separate effort selector with per-model
481
553
  preferences, restricted Codex tools, and separate v3 runtime state. On npm as `pindeck@beta`.
482
554
 
483
- * **2.10.0-beta.1** (2.10 beta): decks with their own file names and shared files, and Claude working
484
- on the whole deck.
555
+ * **2.10**: decks with their own file names and shared files, decks made without reveal.js, and Claude
556
+ working on the whole deck.
485
557
  - A deck file by name: `pindeck path/to/talk.html` (a folder still opens its `index.html`). Several
486
558
  decks can share a folder, each with its own chat and undo history.
487
559
  - Claude may edit and create other files of the deck (scripts, style sheets, notes), never
@@ -492,8 +564,14 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
492
564
  screenshots and PDF exports. A symlink that leads out of the deck and its asset roots is refused.
493
565
  - Shared instructions: the deck's `CLAUDE.md` and `AGENTS.md` may import other files with `@path`.
494
566
  `CLAUDE.md` files in parent folders are still not loaded, on purpose (see the README).
495
- - Decks made with other slide frameworks (Marp, Slidev, impress.js, remark and others) are
496
- recognised and refused with their name, before PinDeck changes anything.
567
+ - Decks made without reveal.js (a script of their own, Shower, deck.js, a hand-written deck): a deck
568
+ that names its slides with `<meta name="pindeck:slides">` (a CSS selector) and their size with
569
+ `<meta name="pindeck:size">` is edited like a reveal.js deck, with screenshots at the slide size and
570
+ one PDF page per slide (contributed by Zeyu Xu). Decks made with other slide frameworks (Marp,
571
+ Slidev, impress.js, remark and others) without these tags are recognised and refused with their
572
+ name, before PinDeck changes anything; the message says how to add the tags.
573
+ - Screenshots fill the picture: headless Chrome lays the page out shorter than its window, which
574
+ left the slide letterboxed in some Chrome versions (reported by Zeyu Xu).
497
575
  - Linux: pictures of PDF pages with Poppler, the PowerPoint import with slide pictures from
498
576
  LibreOffice, and waveforms of non-WAV audio with ffmpeg, each when installed.
499
577
 
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
+ }