pindeck 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # PinDeck
2
2
 
3
+ > **macOS, and Linux as a beta.** On Linux, PinDeck runs as a headless server that you use from your
4
+ > Mac's browser through an SSH tunnel (see [Running on a Linux server](#running-on-a-linux-server-beta)).
5
+ > Windows is not supported.
6
+
3
7
  **Pin it. Say it. Done.** PinDeck is a local editor for HTML slide decks (reveal.js): the slides on
4
8
  the left, the slide in the middle, and a chat with Claude Code on the right. You click elements to
5
9
  pin them, write what to change, and Claude edits the HTML and CSS. The slide updates in place.
@@ -26,6 +30,11 @@ checked the layout; the next request, still being typed, refers to the new pins
26
30
 
27
31
  ## Quick start
28
32
 
33
+ Runs on macOS, and on Linux as a beta. The npm package declares `"os": ["darwin", "linux"]`, so normal
34
+ npm installation rejects other operating systems. Overriding that check does not make those platforms
35
+ supported. Running the server from a cloned repository on another OS shows a local support notice
36
+ instead of opening the deck or starting Claude.
37
+
29
38
  ```bash
30
39
  npx pindeck path/to/deck # from npm
31
40
 
@@ -33,11 +42,13 @@ git clone https://github.com/ehabets/PinDeck.git && cd PinDeck
33
42
  node server.mjs ocean-example # or from source, with the example deck in this repository
34
43
  ```
35
44
 
36
- Then open <http://127.0.0.1:5180/>. Requirements: Node 20 or later and the `claude` CLI, signed in
45
+ Then open the login link PinDeck prints (`PinDeck: http://127.0.0.1:5180/login?code=...`). It works
46
+ once and logs this browser in; for a new one, run `pindeck login` (or `node server.mjs login`). Requirements: Node 20 or later and the `claude` CLI, signed in
37
47
  with your normal Claude account. Chrome (or Chromium, Edge, Brave) is optional and is used for
38
48
  screenshots and PDF export; PowerPoint or Keynote (macOS) for pictures in the PowerPoint import.
39
49
 
40
- Options: `--port 5180`, `--model <name>` (for this run only; otherwise the model in
50
+ PinDeck adds element ids to the deck's `index.html` when it opens it (see below), so try it on a copy
51
+ of a deck first. Options: `--port 5180`, `--model <name>` (for this run only; otherwise the model in
41
52
  `~/.pindeck/config.json`), `--help`. The deck folder defaults to the current folder and must contain
42
53
  `index.html`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck/` (change with
43
54
  `PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
@@ -76,8 +87,9 @@ cp -R ocean-example my-template
76
87
  `pindeck:footer-top` and `pindeck:locked` meta tags if the footer or the brand elements move.
77
88
  `deck.js` holds the math macros, the footer markup and the audio player.
78
89
  4. **`README.md` and `CLAUDE.md`:** Claude follows both, so describe the new classes and rules there.
79
- The example's `CLAUDE.md` calls `../cli.mjs` and `../server.mjs`; fix these paths if the template
80
- does not sit next to PinDeck.
90
+ Refer to PinDeck's commands as "the PinDeck CLI command given in the system prompt", as the
91
+ example's `CLAUDE.md` does; PinDeck tells Claude the right path wherever the deck is. An older
92
+ `node ../cli.mjs` in a deck's `CLAUDE.md` is replaced with that command.
81
93
 
82
94
  **New slide** and the PowerPoint import model new slides on the deck's own: keep a slide with class
83
95
  `title-slide`, one with `section-slide`, and a content slide with `.slide-head` (`h2`, optionally a
@@ -101,6 +113,51 @@ plain players.
101
113
  chat header's menu is written to this file, so it always shows what PinDeck uses. Edit it while
102
114
  PinDeck is stopped, or simply use the menu.
103
115
 
116
+ ## Running on a Linux server (beta)
117
+
118
+ PinDeck can run on a Linux machine where the deck lives, with the editor in your Mac's browser. The
119
+ deck, PinDeck, Claude Code and the deck's git clone all stay on the server; the browser only shows the
120
+ editor. The server listens on 127.0.0.1 there, as on a Mac, and an SSH tunnel brings that port to your Mac.
121
+
122
+ On the server you need Node 20 or later (22 or 24 recommended), git if the deck is in a repository, and
123
+ the `claude` CLI installed and logged in under your account there, if you want the chat. Without it,
124
+ editing by hand still works. Then, in one SSH session on the server:
125
+
126
+ ```bash
127
+ node server.mjs path/to/deck --port 5180
128
+ ```
129
+
130
+ and on your Mac, in another terminal (`myserver` is your usual SSH host):
131
+
132
+ ```bash
133
+ ssh -N -T -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 127.0.0.1:5180:127.0.0.1:5180 myserver
134
+ ```
135
+
136
+ Open the login link PinDeck printed on the server in the Mac's browser. The local port may differ from
137
+ 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
138
+ 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
139
+ server. Restart the tunnel and the editor reconnects and shows the files as they are then; it never sends a
140
+ message twice. If PinDeck restarts, the editor asks you to log in again: run `node server.mjs login
141
+ path/to/deck` on the server for a new link.
142
+
143
+ Other users of a shared server cannot open the editor or read the deck through PinDeck: the token that
144
+ protects it is only in your browser's cookie and in PinDeck's own files in `~/.pindeck` (readable only by
145
+ you), never in a page or on a command line.
146
+
147
+ What works on Linux in this beta: editing by hand, speaker notes, slides, pins, the chat with Claude,
148
+ Undo/Redo, live reload when files change on the server, layout checks, attaching figures, audio and
149
+ PDFs, `git-setup`, and, with Chrome or Chromium installed on the server, screenshots and PDF export
150
+ (`PINDECK_LINUX_CHROME=0` turns these off). What does not, with the reason shown in the editor (hover
151
+ over "Server: Linux (beta)"): pictures of PDF pages (`pdf-figure`, needs macOS PDFKit) and the
152
+ PowerPoint import. Claude is told which commands are not available. Chromium from snap may not be
153
+ allowed to use hidden folders in your home folder such as `~/.pindeck`; if screenshots fail with it, set
154
+ `PINDECK_HOME` to a folder that is not hidden, or install Chrome or Chromium from a package.
155
+
156
+ Run one PinDeck per deck. While PinDeck or Claude is working on a deck, do not edit it with other tools
157
+ or switch git branches at the same time. Stop first, or wait until Claude has finished. PinDeck notices
158
+ changes made by other programs and reloads, and it refuses Undo, Redo or an edit that would overwrite
159
+ them. Its checks cannot rule out every race with a program that writes at the same moment.
160
+
104
161
  ## Keeping the ids out of git
105
162
 
106
163
  PinDeck adds `data-eid` attributes to the deck it opens. If the deck is in git, run once per clone:
@@ -206,7 +263,7 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
206
263
  * **Layout instead of screenshots.** With each message the editor measures the current slide and
207
264
  every pinned slide in your browser: position and size of every element, font sizes, and issues
208
265
  (content below the footer rule, outside the slide, overlaps, clipped text). Claude gets this as
209
- text. To check its own work it runs `node ../cli.mjs layout <slide>...` (`--issues` for the
266
+ text. To check its own work it runs the PinDeck CLI's `layout <slide>...` (`--issues` for the
210
267
  issues only). Screenshots are
211
268
  only taken when you tick the box or Claude needs a visual judgement.
212
269
  * **One Claude Code process.** The server starts `claude -p` in stream-json mode once and keeps it
@@ -217,13 +274,19 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
217
274
  and no MCP servers, so no allow rule in your own, the deck's or a parent folder's settings widens this. If `brand.css` changes during a
218
275
  turn anyway, the server restores it and says so in the chat.
219
276
  * **Undo.** The server keeps a copy of the editable files from before each change (a Claude turn
220
- or a hand edit). Undo and redo walk that history and tell Claude on the next message.
277
+ or a hand edit). Undo and redo walk that history and tell Claude on the next message. They first check
278
+ that the files are still as that change left them; if something else changed them since, they do
279
+ nothing and say which file differs, rather than overwrite that work.
280
+ * **Saving.** Every file PinDeck writes into the deck is written to a temporary file next to it and then
281
+ renamed into place, so other programs never see half a file. Each change from the editor carries the
282
+ revision of the files it was made on; if the files have changed since, the change is refused, the
283
+ slide reloads, and edited text can be copied from the message to type in again.
221
284
 
222
285
  ## Files
223
286
 
224
287
  | File | Purpose |
225
288
  |---|---|
226
- | `server.mjs` | Local server (127.0.0.1 only, random token per run): UI, deck files, events, Claude session, undo, CLI endpoints. |
289
+ | `server.mjs` | Local server (127.0.0.1 only, random token per run, one-time login links): UI, deck files, events, Claude session, undo, CLI endpoints, `login`. |
227
290
  | `cli.mjs` | `layout`, `shot`, `slides`, `pdf handout` / `pdf presentation` and `pdf-figure` commands for Claude (and for you), and `git-setup`. |
228
291
  | `public/` | The editor page, and `bridge.js`, which is injected into the deck in edit mode (never saved to it). |
229
292
  | `lib/pptx.mjs`, `lib/zip.mjs`, `lib/xml.mjs` | Reading `.pptx` files (no dependencies): slides, text, shapes and their positions, notes. |
@@ -234,6 +297,8 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
234
297
  | `lib/pdf.mjs` | PDF export: reveal.js print layout in headless Chrome, then print to PDF. |
235
298
  | `lib/eids.mjs` | Adds the `data-eid` attributes by editing the HTML as text. |
236
299
  | `lib/claude.mjs` | The long-running Claude Code process. |
300
+ | `lib/capabilities.mjs` | What this server can do on this system (macOS, Linux), for the editor, the API, the CLI and Claude. |
301
+ | `lib/deck-files.mjs` | Atomic writes and the revision of the deck's files. |
237
302
  | `lib/system-prompt.md` | What Claude is told about the editor. |
238
303
  | `CONTRIBUTING.md` | How to report bugs and contribute. |
239
304
  | `docs/` | Images for this README. |
@@ -246,9 +311,39 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
246
311
  * 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 Claude for those.
247
312
  * Moves are offsets (`translate`), so the space an element came from stays reserved. Ask Claude to turn hand edits into proper CSS when a layout should really change.
248
313
  * The undo history lives on the server and starts fresh when PinDeck restarts.
314
+ * One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
315
+ * Linux (beta): no PDF page pictures or PowerPoint import yet; tested hosts are listed in the release notes.
249
316
 
250
317
  ## Versions
251
318
 
319
+ * **2.9**: Linux as a beta, and safer editing.
320
+ - Linux (beta): PinDeck runs as a headless server, used from a Mac's browser through an SSH tunnel,
321
+ with Claude Code running on the server. The tunnel may use another local port than the server.
322
+ Screenshots, layout checks without an open editor and PDF export work when Chrome or Chromium is
323
+ found (`PINDECK_LINUX_CHROME=0` turns them off). Features a system cannot provide are switched off,
324
+ with the reason shown in the editor. Changes made to the deck by other programs are seen every
325
+ time, also after a file was replaced by a rename.
326
+ - Security: the editor page no longer contains the token, and the deck needs it too, so other users
327
+ of the same machine cannot read either. PinDeck prints a one-time login link at start
328
+ (`pindeck login` prints a new one), which keeps the token in an HttpOnly cookie named after the
329
+ server's port; headless Chrome logs in the same way, so the token is never on a command line. After
330
+ a restart, log in again with a new link.
331
+ - Saving is atomic; edits made on files that changed meanwhile are refused, and Undo/Redo no longer
332
+ overwrite changes made outside PinDeck.
333
+ - Audio and video play in Safari: deck files are sent with their length and byte ranges, and ogg,
334
+ opus, m4a, aac, flac and mov get their media types.
335
+ - Claude gets the right CLI command for decks outside the PinDeck folder; the example's `CLAUDE.md`
336
+ no longer names a path. The deck's `AGENTS.md` reaches Claude along with its `CLAUDE.md`.
337
+ - macOS: pictures of PDF pages (`pdf-figure`) and of PowerPoint slides have a white page instead of a
338
+ transparent one, so dark text no longer disappears on a dark slide.
339
+ - Screenshots and PDF exports are no longer now and then reported as failed although Chrome wrote them.
340
+ - `git-setup` works for decks behind symlinks or with spaces in their path. The tests run on Linux
341
+ and macOS in CI, including a real Chrome.
342
+
343
+ * **2.8.2**: declares macOS-only support in npm metadata and the README to prevent accidental
344
+ installation on unsupported operating systems. Source launches on other platforms show a support
345
+ notice without opening the deck.
346
+
252
347
  * **2.8.1**: fixes from a review. Speaker notes that fail to load leave the field empty and locked, instead
253
348
  of showing the previous slide's notes and saving your edits over them. A save that fails because the
254
349
  server cannot be reached is reported, and the edits after it (drags, text, bullets, notes) are still
package/cli.mjs CHANGED
@@ -31,14 +31,16 @@ const arg = rest[0];
31
31
  on where PinDeck is installed; git config is per clone, so run this once in each clone. */
32
32
  if (cmd === "git-setup") {
33
33
  const FILTER = `sed 's/ data-eid="[^"]*"//g'`;
34
- const deck = path.resolve(arg || ".");
34
+ let deck = path.resolve(arg || ".");
35
+ try { deck = fs.realpathSync(deck); } catch {} // git reports the real path; a symlinked path would not match
35
36
  const git = (...a) => execFileSync("git", ["-C", deck, ...a], { encoding: "utf8" }).trim();
36
37
  let root;
37
38
  try { root = git("rev-parse", "--show-toplevel"); }
38
39
  catch { console.error(`${deck} is not inside a git repository.`); process.exit(1); }
39
40
  git("config", "filter.pindeck-ids.clean", FILTER);
40
41
  const rel = path.relative(root, deck).split(path.sep).join("/");
41
- const pattern = `${rel ? rel + "/" : ""}*.html`;
42
+ // .gitattributes patterns end at whitespace: spaces in folder names are written as [[:space:]]
43
+ const pattern = `${rel ? rel.replace(/\s/g, "[[:space:]]") + "/" : ""}*.html`;
42
44
  const attrFile = path.join(root, ".gitattributes");
43
45
  const attrs = fs.existsSync(attrFile) ? fs.readFileSync(attrFile, "utf8") : "";
44
46
  const line = `${pattern} filter=pindeck-ids`;
@@ -0,0 +1,112 @@
1
+ /* What this server can do on this machine, decided once on the backend and used everywhere: the editor
2
+ (status), the API (refusals before any side effect), the CLI (its error text) and Claude's system prompt.
3
+
4
+ macOS: everything, as before. Linux (beta): editing, the chat and live reload. Features that need macOS
5
+ (PDFKit, PowerPoint/Keynote, afconvert) are off with a reason. Chrome features (screenshots, headless
6
+ layout, PDF export) work on Linux as on macOS when Chrome or Chromium is found; PINDECK_LINUX_CHROME=0
7
+ turns them off. Each entry is { available, reason }: reason says why not, in plain words. */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ export const SUPPORTED_PLATFORMS = ["darwin", "linux"];
13
+ export const isSupportedPlatform = (platform) => SUPPORTED_PLATFORMS.includes(platform);
14
+ export const platformLabel = (platform) => ({ darwin: "macOS", linux: "Linux (beta)" }[platform] || platform);
15
+
16
+ const CHROME_PATHS = {
17
+ darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
18
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
19
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
20
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
21
+ linux: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
22
+ "/snap/bin/chromium"],
23
+ };
24
+ const OFFICE_APPS = ["/Applications/Microsoft PowerPoint.app", "/Applications/Keynote.app"];
25
+
26
+ const isExecutable = (p) => {
27
+ try { return fs.statSync(p).isFile() && (fs.accessSync(p, fs.constants.X_OK), true); } catch { return false; }
28
+ };
29
+
30
+ /* An executable by path, or by name on PATH. Never runs it. Returns { path } or { path: null, reason }. */
31
+ export function findExecutable(name, { env = process.env } = {}) {
32
+ if (name.includes("/")) {
33
+ if (isExecutable(name)) return { path: name };
34
+ return { path: null, reason: fs.existsSync(name) ? `${name} is not an executable file` : `${name} does not exist` };
35
+ }
36
+ for (const dir of String(env.PATH || "").split(path.delimiter).filter(Boolean)) {
37
+ const p = path.join(dir, name);
38
+ if (isExecutable(p)) return { path: p };
39
+ }
40
+ return { path: null, reason: `no "${name}" command on the PATH` };
41
+ }
42
+
43
+ const on = () => ({ available: true, reason: "" });
44
+ const off = (reason) => ({ available: false, reason });
45
+
46
+ export function detectCapabilities({ platform = process.platform, env = process.env } = {}) {
47
+ const mac = platform === "darwin";
48
+ const caps = { platform, label: platformLabel(platform) };
49
+
50
+ const claudeBin = env.CLAUDE_BIN || "claude";
51
+ const claude = findExecutable(claudeBin, { env });
52
+ caps.claude = claude.path ? on() : off(`Claude Code is not available on the server (${claude.reason}). ` +
53
+ `Install and log in to the claude CLI there, or set CLAUDE_BIN. Editing by hand still works.`);
54
+
55
+ let chrome = null, chromeWhy = "";
56
+ if (env.CHROME_BIN) {
57
+ const c = findExecutable(env.CHROME_BIN, { env });
58
+ chrome = c.path;
59
+ chromeWhy = c.reason ? `CHROME_BIN: ${c.reason}` : "";
60
+ } else {
61
+ chrome = (CHROME_PATHS[platform] || []).find(isExecutable) || null;
62
+ chromeWhy = chrome ? "" : "no Chrome or Chromium found (set CHROME_BIN)";
63
+ }
64
+ if (chrome && platform === "linux" && env.PINDECK_LINUX_CHROME === "0") {
65
+ chrome = null;
66
+ chromeWhy = "Chrome features are turned off (PINDECK_LINUX_CHROME=0)";
67
+ }
68
+ const chromeCap = chrome ? on() : off(chromeWhy);
69
+ caps.chromePath = chrome;
70
+ caps.screenshot = chromeCap;
71
+ caps.headlessLayout = chromeCap;
72
+ caps.pdfExport = chromeCap;
73
+
74
+ caps.pdfPages = mac ? on() : off("Rendering PDF pages to pictures needs macOS (PDFKit). Attached PDFs can still be read.");
75
+ caps.pptxImport = mac ? on() : off("PowerPoint import is not available on Linux in this beta.");
76
+ caps.pptxPictures = mac && OFFICE_APPS.some((a) => fs.existsSync(a)) ? on()
77
+ : off(mac ? "Pictures of PowerPoint slides need PowerPoint or Keynote." : "Pictures of PowerPoint slides need macOS with PowerPoint or Keynote.");
78
+ caps.audioConvert = mac ? on() : off("Waveforms for audio other than WAV need macOS (afconvert).");
79
+ return caps;
80
+ }
81
+
82
+ /* The capabilities for the editor and the status API: no executable paths. */
83
+ export const publicCapabilities = ({ chromePath, ...caps }) => caps;
84
+
85
+ /* The lines of Claude's system prompt about what this server cannot do. Empty on a full macOS setup. */
86
+ export function capabilityPrompt(caps, cli) {
87
+ const lines = [];
88
+ if (!caps.screenshot.available)
89
+ lines.push(`- \`${cli} shot\` does not work here: ${caps.screenshot.reason}. Use \`${cli} layout\` to check slides; ` +
90
+ `it works while the editor is open in the user's browser.`);
91
+ if (!caps.pdfExport.available) lines.push(`- \`${cli} pdf\` does not work here: ${caps.pdfExport.reason}.`);
92
+ if (!caps.pdfPages.available) lines.push(`- \`${cli} pdf-figure\` does not work here: ${caps.pdfPages.reason}`);
93
+ if (!lines.length) return "";
94
+ return `\n\n## This PinDeck server (${caps.label})\nSome commands are not available, so do not try them:\n${lines.join("\n")}\n`;
95
+ }
96
+
97
+ /* --port / PORT: an integer 0–65535 (0: any free port, for tests). null if invalid. */
98
+ export function parsePort(value) {
99
+ const s = String(value ?? "").trim();
100
+ if (!/^\d+$/.test(s)) return null;
101
+ const n = Number(s);
102
+ return n <= 65535 ? n : null;
103
+ }
104
+
105
+ /* The command Claude runs for the CLI, quoted for the shell. The Bash permission is this exact prefix, so
106
+ it must be one fixed string; double quotes as before, single quotes when the path has characters that
107
+ double quotes do not protect. */
108
+ export function cliCommand(rel) {
109
+ if (/^[\w./@%+=:,-]+$/.test(rel)) return `node ${rel}`;
110
+ if (!/["$`\\!]/.test(rel)) return `node "${rel}"`;
111
+ return `node '${rel.replaceAll("'", `'\\''`)}'`;
112
+ }
package/lib/claude.mjs CHANGED
@@ -20,7 +20,7 @@ export class ClaudeSession {
20
20
  const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
21
21
  ...(typeof this.args === "function" ? this.args() : this.args), ...(resumeId ? ["--resume", resumeId] : [])];
22
22
  const proc = spawn(process.env.CLAUDE_BIN || "claude", args, {
23
- cwd: this.cwd, env: { ...process.env, ...this.env }, stdio: ["pipe", "pipe", "pipe"],
23
+ cwd: this.cwd, env: { ...process.env, ...(typeof this.env === "function" ? this.env() : this.env) }, stdio: ["pipe", "pipe", "pipe"],
24
24
  });
25
25
  this.proc = proc;
26
26
  readline.createInterface({ input: proc.stdout }).on("line", (line) => {
@@ -45,6 +45,7 @@ export class ClaudeSession {
45
45
  };
46
46
  proc.on("error", (err) => { this.onStderr?.(String(err)); if (proc.pid === undefined) end(null, null); });
47
47
  proc.on("exit", end);
48
+ proc.exited = new Promise((resolve) => proc.once("close", resolve).once("error", () => proc.pid === undefined && resolve()));
48
49
  }
49
50
 
50
51
  send(content) {
@@ -55,12 +56,16 @@ export class ClaudeSession {
55
56
  this.#write({ type: "control_request", request_id: `int-${++this.reqId}`, request: { subtype: "interrupt" } });
56
57
  }
57
58
 
59
+ /* End of input first (Claude Code finishes and exits), SIGTERM after 1.5 s, SIGKILL after 4 s.
60
+ Resolves once the process has exited, so shutdown can wait for it. */
58
61
  stop() {
59
- if (!this.proc) return;
62
+ if (!this.proc) return Promise.resolve();
60
63
  const p = this.proc;
61
64
  this.proc = null;
62
65
  p.stdin.end();
63
- setTimeout(() => p.kill("SIGTERM"), 1500);
66
+ const term = setTimeout(() => p.kill("SIGTERM"), 1500);
67
+ const kill = setTimeout(() => p.kill("SIGKILL"), 4000);
68
+ return p.exited.finally(() => { clearTimeout(term); clearTimeout(kill); });
64
69
  }
65
70
 
66
71
  #write(obj) {
@@ -0,0 +1,56 @@
1
+ /* Writing deck files safely, and telling whether they changed.
2
+
3
+ writeAtomic: the new content goes to a temporary file next to the target, is flushed, gets the target's
4
+ permissions and then replaces the target with one rename. A reader (the browser, Claude, an editor, the
5
+ watcher) sees the old file or the new one, never half of it. A symlinked target is written where the link
6
+ points, so the link stays a link. Each file is atomic on its own; a set of files is not one transaction.
7
+
8
+ revision: a hash of the deck's editable files as they are now. The editor sends the revision it shows
9
+ with each change; a different one means someone else changed the files in between (409, nothing written).
10
+ This catches stale edits from the editor. It is not a lock: a program that writes between PinDeck's check
11
+ and its write can still lose its change, so only one writer (PinDeck, Claude, or another tool) at a time. */
12
+
13
+ import crypto from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ export function writeAtomic(file, data) {
18
+ let target = file, mode = null;
19
+ try {
20
+ if (fs.lstatSync(file).isSymbolicLink()) target = fs.realpathSync(file);
21
+ mode = fs.statSync(target).mode & 0o7777;
22
+ } catch (e) { if (e.code !== "ENOENT") throw e; }
23
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.pindeck-${crypto.randomBytes(6).toString("hex")}.tmp`);
24
+ let fd = null;
25
+ try {
26
+ fd = fs.openSync(tmp, "wx", mode ?? 0o666); // wx: never reuse an existing file
27
+ fs.writeFileSync(fd, data);
28
+ fs.fsyncSync(fd);
29
+ if (mode != null) fs.fchmodSync(fd, mode); // the umask must not change the file's permissions
30
+ fs.closeSync(fd);
31
+ fd = null;
32
+ fs.renameSync(tmp, target);
33
+ } catch (e) {
34
+ if (fd != null) try { fs.closeSync(fd); } catch {}
35
+ try { fs.rmSync(tmp, { force: true }); } catch {}
36
+ throw new Error(`Could not save ${path.basename(file)}: ${e.message}`);
37
+ }
38
+ }
39
+
40
+ /* Same bytes? (a missing file is null) */
41
+ export function sameContent(file, data) {
42
+ let now;
43
+ try { now = fs.readFileSync(file); } catch (e) { if (e.code === "ENOENT") return data == null; throw e; }
44
+ return data != null && now.equals(Buffer.isBuffer(data) ? data : Buffer.from(data));
45
+ }
46
+
47
+ export function revision(dir, files) {
48
+ const h = crypto.createHash("sha256");
49
+ for (const f of files) {
50
+ let c = null;
51
+ try { c = fs.readFileSync(path.join(dir, f)); } catch {}
52
+ h.update(f + (c == null ? "\0-" : `\0${c.length}\0`));
53
+ if (c) h.update(c);
54
+ }
55
+ return h.digest("hex").slice(0, 16);
56
+ }
@@ -39,10 +39,12 @@ function run(argv) {
39
39
  null, w, h, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0);
40
40
  const ctx = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep);
41
41
  $.NSGraphicsContext.saveGraphicsState;
42
- $.NSGraphicsContext.currentContext = ctx;
43
- $.NSColor.whiteColor.setFill;
44
- $.NSRectFill($.NSMakeRect(0, 0, w, h));
42
+ $.NSGraphicsContext.setCurrentContext(ctx);
45
43
  const cg = ctx.CGContext;
44
+ // a white page, as in a PDF viewer (and as Poppler renders on Linux): most PDFs paint no background,
45
+ // and a transparent picture would show dark text on a dark slide
46
+ $.CGContextSetRGBFillColor(cg, 1, 1, 1, 1);
47
+ $.CGContextFillRect(cg, $.CGRectMake(0, 0, w, h));
46
48
  $.CGContextScaleCTM(cg, scale, scale);
47
49
  $.CGContextTranslateCTM(cg, tx, ty);
48
50
  page.drawWithBoxToContext($.kPDFDisplayBoxMediaBox, cg);
package/lib/pdf.mjs CHANGED
@@ -46,7 +46,8 @@ function chromeRun(chrome, args, profile, done, timeout) {
46
46
  proc.stdout.on("data", (d) => { out += d; check(); });
47
47
  proc.stderr.on("data", (d) => { err += d; check(); });
48
48
  proc.on("error", (e) => finish(e));
49
- proc.on("exit", () => (done(out, err) ? finish(null, out) : finish(new Error("Chrome exited early."))));
49
+ // "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
50
+ proc.on("close", () => (done(out, err) ? finish(null, out) : finish(new Error("Chrome exited early."))));
50
51
  });
51
52
  }
52
53
 
@@ -61,7 +62,8 @@ export async function printLayout(chrome, url, profile) {
61
62
  /* Step 2: print a (static) page to a PDF file. */
62
63
  export async function printPdf(chrome, url, out, profile) {
63
64
  try { fs.rmSync(out, { force: true }); } catch {}
64
- await chromeRun(chrome, ["--no-pdf-header-footer", `--print-to-pdf=${out}`, url], profile,
65
+ // the virtual-time budget lets Chrome follow PinDeck's login start page to the print page before printing
66
+ await chromeRun(chrome, ["--virtual-time-budget=5000", "--no-pdf-header-footer", `--print-to-pdf=${out}`, url], profile,
65
67
  (_o, e) => /bytes written to file/.test(e), 90000);
66
68
  if (!fs.existsSync(out)) throw new Error("Chrome did not write the PDF.");
67
69
  return out;
@@ -0,0 +1,34 @@
1
+ import http from 'node:http';
2
+
3
+ export function createPlatformNoticeServer(platform) {
4
+ const windows = platform === 'win32';
5
+ const heading = windows ? 'Windows support is on the roadmap' : 'PinDeck runs on macOS and Linux';
6
+ const explanation = windows
7
+ ? 'PinDeck runs on macOS and, as a beta, on Linux. Windows support is planned for a future version; there is no release date yet.'
8
+ : 'This version of PinDeck runs on macOS and, as a beta, on Linux. This operating system is not supported.';
9
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PinDeck · system not supported</title><style>
10
+ :root{font-family:system-ui,sans-serif;color:#243348;background:#f4f6f8;color-scheme:light}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(100%,620px);padding:40px;background:white;border:1px solid #dde3ea;border-radius:18px;box-shadow:0 12px 40px #20334d0a}.brand{font-weight:700;color:#c36516;margin:0 0 32px}h1{font-size:clamp(26px,5vw,36px);line-height:1.2;margin:0 0 20px}p{line-height:1.65}a{color:#945012}footer{margin-top:28px;font-size:14px;color:#596577}
11
+ </style></head><body><main><p class="brand">PinDeck</p><h1>${heading}</h1><p>${explanation}</p><p>Your deck has not been opened or changed, and no AI backend has been started.</p><p>You can run PinDeck on a Mac or a Linux machine (and use it from this browser through an SSH tunnel), or follow updates on <a href="https://github.com/ehabets/PinDeck">GitHub</a>.</p><footer>Close this tab and press Ctrl+C in the terminal to stop this notice.</footer></main></body></html>`;
12
+ return http.createServer((req, res) => {
13
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { Allow: 'GET, HEAD' }); return res.end(); }
14
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
15
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
16
+ res.end(req.method === 'HEAD' ? undefined : html);
17
+ });
18
+ }
19
+
20
+ // Keep startup here until the notice is closed: no deck/state initialization runs on unsupported OSes.
21
+ export async function showPlatformNotice(port, platform = process.platform) {
22
+ const server = createPlatformNoticeServer(platform);
23
+ await new Promise((resolve, reject) => {
24
+ const stop = () => server.close();
25
+ server.once('error', reject);
26
+ server.once('close', () => {
27
+ process.off('SIGINT', stop); process.off('SIGTERM', stop); resolve();
28
+ });
29
+ server.listen(port, '127.0.0.1', () => {
30
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
31
+ console.log(`PinDeck runs on macOS and Linux only. Open http://127.0.0.1:${server.address().port}/ for details. Press Ctrl+C to stop.`);
32
+ });
33
+ });
34
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pindeck",
3
- "version": "2.8.1",
4
- "description": "PinDeck: a local editor for HTML slide decks. Pin elements, describe the change, and Claude Code edits the slides.",
3
+ "version": "2.9.0",
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 edits the slides.",
5
5
  "keywords": [
6
6
  "slides",
7
7
  "presentation",
@@ -35,6 +35,10 @@
35
35
  "start": "node server.mjs",
36
36
  "test": "node --test"
37
37
  },
38
+ "os": [
39
+ "darwin",
40
+ "linux"
41
+ ],
38
42
  "engines": {
39
43
  "node": ">=20"
40
44
  }
package/public/editor.css CHANGED
@@ -65,6 +65,7 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
65
65
  }
66
66
  .pos { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 52px; text-align: center; }
67
67
  .deck-name { min-width: 0; margin-left: 8px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
68
+ .server-note { flex: none; margin-left: 8px; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: #6b4a12; background: #fbf0dc; cursor: help; }
68
69
  .toggle { display: inline-flex; align-items: center; gap: 6px; }
69
70
  .toggle .dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; background: var(--orange); color: #fff;
70
71
  font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; }