From 0ec713408885b56d465c2e72110c62f728e265fa Mon Sep 17 00:00:00 2001 From: Bernard <63512176+BernardJen@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:02:50 +0200 Subject: [PATCH] feat(plot): save and open projects, on this device or on the plotter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drawings were transient. The session persists — on the Pi, shared with every client — but there is exactly one of it: importing the next job overwrites the last, and there is no way to put a plot aside and come back to it. A headless machine in a workshop should hold a library of plots, not the most recent one. A project is the whole job, and it carries its pens. Artwork names pens by id, so a project opened on another machine — or after the library has been edited — would otherwise render in whatever pens happen to exist there. Opening merges them in rather than replacing the library, so nothing the operator defined disappears. Versioned from the first release: these files outlive the app that wrote them, and "we will add a version when we need one" means the first file that needs it cannot be read. A file from a newer version is refused with a message rather than opened with its unknown fields silently dropped, and `readProject` returns the reason instead of throwing, because every caller wants to show it. The name is a security boundary. It arrives over a socket with no authentication and becomes a filesystem path on the Pi, so it goes through a pure, unit-tested sanitiser shared with the client — and the daemon then checks the *resolved* path is inside the projects directory anyway. Either check would do; having both means a bug in one is not a way out of the directory. Saves are atomic, because a project half-written by a power cut is unopenable and the operator would not find out until they came to plot it. A load is addressed to the client that asked: opening a project replaces what is on screen, which is not something to do to another operator's session because someone else clicked a name. The persist effect and the project saver now share one session builder. Two copies of that field list would drift, and the field someone forgets to add to the second one is a field that quietly vanishes from every saved project. Coverage floors raised to 72/68/62/72 — the pure core is at 75% and a floor that cannot fail is not a gate. Verified against the real daemon: save writes into the projects directory and broadcasts the listing, another client sees it on attach, a load reaches only the asking client, `../escaped` lands *inside* the directory under a sanitised name, a nameless project is refused, a missing one reports it, delete removes and re-broadcasts, and the library survives a restart. And in the browser: save to the plotter, clear the page, reopen from the list, drawing back; opening a file merges its pens; a stray JSON and a future-version file are both refused with the right message. Closes #8 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 + README.md | 4 + gateway/server.ts | 113 +++++++++- .../2026-09-12-projects/.openspec.yaml | 2 + .../archive/2026-09-12-projects/design.md | 39 ++++ .../archive/2026-09-12-projects/proposal.md | 35 +++ .../specs/gateway-protocol/spec.md | 22 ++ .../specs/projects/spec.md | 83 ++++++++ .../archive/2026-09-12-projects/tasks.md | 31 +++ openspec/specs/gateway-protocol/spec.md | 21 ++ openspec/specs/projects/spec.md | 83 ++++++++ packaging/penplotter271.env | 1 + src/gateway/protocol.ts | 18 ++ src/plot/__tests__/project.test.ts | Bin 0 -> 4294 bytes src/plot/project.ts | 126 +++++++++++ src/transport/GatewayClient.ts | 24 ++- src/ui/App.tsx | 200 ++++++++++++++++-- vite.config.ts | 8 +- 18 files changed, 794 insertions(+), 23 deletions(-) create mode 100644 openspec/changes/archive/2026-09-12-projects/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-12-projects/design.md create mode 100644 openspec/changes/archive/2026-09-12-projects/proposal.md create mode 100644 openspec/changes/archive/2026-09-12-projects/specs/gateway-protocol/spec.md create mode 100644 openspec/changes/archive/2026-09-12-projects/specs/projects/spec.md create mode 100644 openspec/changes/archive/2026-09-12-projects/tasks.md create mode 100644 openspec/specs/projects/spec.md create mode 100644 src/plot/__tests__/project.test.ts create mode 100644 src/plot/project.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e3211..0bde704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Save and load projects.** A versioned project file holds the whole job — objects, + placements, pens per object, paper, mode and magnets — plus the pen library it refers to, + so a drawing opened on another machine still comes out in the right colours. Save to your + own device as a file, or to the plotter, which now keeps a library every connected client + can list, open and delete (`PLOTTER_PROJECTS`, default `gateway/projects/`). Project names + are sanitised before they become paths on the Pi, and the resolved path is checked against + the projects directory as well. - **Import from anything.** One import that identifies a file by its *contents* rather than its name — operators rename files, and a phone hands over an `image.jpg` that is really HEIC. **PDF** is now a first-class source: a vector page imports as lines at the size the diff --git a/README.md b/README.md index a018856..7958461 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ unattended **Raspberry Pi** setup needs. Confirm and it carries on exactly where it stopped. The pause is a G-code comment the streamer holds on — not `M0` — so the hold belongs to the daemon: reload the page, or walk up with a phone, and the prompt is still there to answer. +- **Save and open projects.** A project is the whole job — objects, placements, pens, paper, + mode and magnets, plus the pens it refers to. Keep it as a file on your own machine, or + store it on the plotter, where it joins a library every connected device can see and open. - **Magnets.** The bed has no vacuum, so tell the app where the hold-down magnets are: they're drawn as keep-out circles you can drag, **Suggest** offers clear positions at the sheet's edges, and **pen-up travel is routed around them** — the detour is drawn on the @@ -180,6 +183,7 @@ On macOS the daemon automatically runs `caffeinate -dimsu` for its lifetime so i | `PLOTTER_STATE` | `gateway/.plotter-state.json` | Where the remembered position is persisted | | `PLOTTER_SESSION` | `gateway/.session.json` | Where the shared editable session (artwork + page) is persisted | | `PLOTTER_APP_SETTINGS` | `gateway/.app-settings.json` | Where app settings (machine setup, preferences) are persisted — shared by every client | +| `PLOTTER_PROJECTS` | `gateway/projects/` | Where saved projects are stored, one file per project | | `GATEWAY_ALLOWED_ORIGINS` | _(none)_ | Extra browser origins allowed to open the WebSocket, comma-separated. Same-origin always passes; add `http://localhost:5173` when driving a live daemon from the Vite dev server | ## Registration (cut to a printed sticker) diff --git a/gateway/server.ts b/gateway/server.ts index ed020b0..88d5e63 100644 --- a/gateway/server.ts +++ b/gateway/server.ts @@ -1,6 +1,6 @@ import { createServer, type IncomingMessage } from 'node:http'; import { spawn } from 'node:child_process'; -import { readFile, writeFile, rename } from 'node:fs/promises'; +import { readFile, writeFile, rename, readdir, mkdir, unlink, stat } from 'node:fs/promises'; import { readFileSync, writeFileSync, renameSync, openSync, fsyncSync, closeSync } from 'node:fs'; import { dirname, extname, join, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,7 +9,14 @@ import { GrblController } from '../src/grbl/GrblController'; import { NodeSerialTransport } from './NodeSerialTransport'; import { isOriginAllowed, parseAllowedOrigins } from './origin'; import { DEFAULT_GATEWAY_PORT } from '../src/gateway/protocol'; -import type { ClientMessage, Snapshot, StreamDebug, UpdateStatus } from '../src/gateway/protocol'; +import type { + ClientMessage, + ProjectSummary, + Snapshot, + StreamDebug, + UpdateStatus, +} from '../src/gateway/protocol'; +import { projectFileName, sanitizeProjectName } from '../src/plot/project'; import { appSettingsFromLegacySession, normalizeAppSettings, @@ -47,6 +54,10 @@ const SESSION_FILE = const APP_SETTINGS_FILE = process.env.PLOTTER_APP_SETTINGS ?? join(fileURLToPath(new URL('.', import.meta.url)), '.app-settings.json'); +// Saved projects live on the Pi so it holds a library of plots any client can +// open — one file per project, in a directory of their own. +const PROJECTS_DIR = + process.env.PLOTTER_PROJECTS ?? join(fileURLToPath(new URL('.', import.meta.url)), 'projects'); // ---- self-update config ---- // Where the update oneshot records its progress; the daemon reads it back after @@ -166,6 +177,47 @@ function saveAppSettings(raw: unknown) { .catch(() => undefined); } +// ---- stored projects ---- +const PROJECT_SUFFIX = '.plot.json'; + +/** List stored projects, newest first. Never throws: a missing directory is empty. */ +async function listProjects(): Promise { + try { + const names = await readdir(PROJECTS_DIR); + const out: ProjectSummary[] = []; + for (const file of names) { + if (!file.endsWith(PROJECT_SUFFIX)) continue; + const info = await stat(join(PROJECTS_DIR, file)).catch(() => null); + out.push({ + name: file.slice(0, -PROJECT_SUFFIX.length), + savedAt: info ? info.mtime.toISOString() : '', + }); + } + return out.sort((a, b) => b.savedAt.localeCompare(a.savedAt)); + } catch { + return []; + } +} + +/** + * The path a project is stored at, or null if the name is unusable. + * + * The name arrives over the WebSocket, so it is sanitised (shared with the + * client, and unit-tested there) and then checked *again* against the resolved + * path: defence in depth, because this is the one place a remote string + * becomes a filesystem path on the Pi. + */ +function projectPath(name: string): string | null { + const safe = sanitizeProjectName(name); + if (!safe) return null; + const path = join(PROJECTS_DIR, projectFileName(safe)); + return path.startsWith(PROJECTS_DIR + '/') ? path : null; +} + +async function broadcastProjects(): Promise { + broadcast({ type: 'event', event: 'projects', payload: await listProjects() }); +} + /** * Restore the remembered work position after (re)connecting — a port open resets * the controller and (with no homing) it forgets where it is. Telling it the @@ -419,9 +471,15 @@ function snapshot(ws: WebSocket): Snapshot { session, appSettings, penChange: ctrl.penChange, + // Filled in by the caller: listing the directory is async, and a snapshot + // has to be ready the moment a client attaches. + projects: knownProjects, }; } +// Cached listing, refreshed whenever a project is written or removed. +let knownProjects: ProjectSummary[] = []; + function releaseControlOnClose(ws: WebSocket) { clients.delete(ws); if (controller === ws) { @@ -492,6 +550,53 @@ async function handleCommand(ws: WebSocket, msg: ClientMessage) { case 'continueProgram': ctrl.continueProgram(); break; + case 'saveProject': { + const path = projectPath(msg.name); + if (!path) { + send(ws, { type: 'cmdError', id, message: 'That project name cannot be used.' }); + return; + } + await mkdir(PROJECTS_DIR, { recursive: true }); + // Atomic: a project half-written by a power cut would be unopenable, + // and the operator would not know until they came to plot it. + const tmp = `${path}.tmp`; + await writeFile(tmp, JSON.stringify(msg.project)); + await rename(tmp, path); + knownProjects = await listProjects(); + await broadcastProjects(); + break; + } + case 'loadProject': { + const path = projectPath(msg.name); + if (!path) { + send(ws, { type: 'cmdError', id, message: 'No such project.' }); + return; + } + const raw = await readFile(path, 'utf8').catch(() => null); + if (raw === null) { + send(ws, { type: 'cmdError', id, message: `No project named "${msg.name}".` }); + return; + } + // Only to the client that asked: opening a project replaces what is on + // screen, which is not something to do to another operator's session. + send(ws, { + type: 'event', + event: 'projectLoaded', + payload: { name: msg.name, project: JSON.parse(raw) }, + }); + break; + } + case 'deleteProject': { + const path = projectPath(msg.name); + if (!path) { + send(ws, { type: 'cmdError', id, message: 'No such project.' }); + return; + } + await unlink(path).catch(() => undefined); + knownProjects = await listProjects(); + await broadcastProjects(); + break; + } case 'saveAppSettings': saveAppSettings(msg.settings); // Push to the *other* clients so every device shows one setup. Echoing @@ -625,6 +730,10 @@ for (const sig of ['SIGINT', 'SIGTERM'] as const) { }); } +void listProjects().then((list) => { + knownProjects = list; +}); + httpServer.listen(PORT, HOST, () => { log(`PenPlotter271 gateway v${APP_VERSION}`); log( diff --git a/openspec/changes/archive/2026-09-12-projects/.openspec.yaml b/openspec/changes/archive/2026-09-12-projects/.openspec.yaml new file mode 100644 index 0000000..2b596d1 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-12 diff --git a/openspec/changes/archive/2026-09-12-projects/design.md b/openspec/changes/archive/2026-09-12-projects/design.md new file mode 100644 index 0000000..40119e2 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/design.md @@ -0,0 +1,39 @@ +## Context + +The session already round-trips through the daemon as an opaque blob, which is most of the +machinery a project needs. What it lacks is identity (a name), plurality (a library), and the pens +the artwork refers to. + +## Decisions + +- **A project carries its pens.** Artwork names pens by id; a project opened on another machine — + or after the library has been edited — would otherwise render in whatever pens happen to exist. + Opening merges the project's pens into the library rather than replacing it, so nothing the + operator has defined disappears. +- **Versioned from the first release.** These files outlive the app that wrote them, and "we will + add a version when we need one" means the first file that needs it cannot be read. A file from a + *newer* version is refused with a message rather than being opened with fields silently dropped. +- **`readProject` returns a reason instead of throwing.** Every caller wants to show it: "that is + not a project file" is the useful answer to opening the wrong JSON. +- **One session builder.** The persist effect and the project saver use the same function. Two + copies of that field list would drift, and the field someone forgets to add to the second one is + a field that quietly vanishes from every saved project. +- **Name sanitisation is a security boundary, and is checked twice.** The name arrives over a + socket with no authentication and becomes a filesystem path on the Pi. It is reduced to a safe + name by a pure, unit-tested function shared with the client, and the daemon then verifies the + *resolved* path is inside the projects directory before writing. Either check alone would do; + having both means a bug in one is not a way out of the directory. +- **A load goes only to the client that asked.** Opening a project replaces what is on screen — + not something to do to another operator's session because someone else clicked a name. +- **Saves are atomic.** A project half-written by a power cut would be unopenable, and the operator + would not find out until they came to plot it. +- **The listing is cached and broadcast.** A snapshot has to be ready the instant a client attaches, + and listing a directory is asynchronous. + +## Risks / Trade-offs + +- A project stores the artwork's *geometry*, not the file it was imported from, so reopening gives + the drawing back but not the ability to retune an import's conversion. That matches the session, + and keeping megabytes of source per project on a Pi's SD card is not obviously better. +- There is no rename, and saving under an existing name overwrites it. Both are easy to add once + it is clear how operators actually organise these. diff --git a/openspec/changes/archive/2026-09-12-projects/proposal.md b/openspec/changes/archive/2026-09-12-projects/proposal.md new file mode 100644 index 0000000..c5f9194 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/proposal.md @@ -0,0 +1,35 @@ +## Why + +Drawings were transient. The session persists — on the Pi, shared with every client — but there is +exactly one of it: importing the next job overwrites the last one, and there is no way to put a +plot aside and come back to it. A headless machine in a workshop should hold a library of plots, +not the most recent one. + +## What Changes + +- **A versioned project file** (`src/plot/project.ts`): the whole job — objects, placements, pens + per object, paper, mode, magnets — plus the pen library it refers to, because artwork names pens + by id and a project opened elsewhere would otherwise come out in the wrong colours. +- **Save to this device**: a file the operator keeps, and can open again. +- **Save to the plotter**: the daemon stores projects in a directory of their own, lists them to + every client, serves one to the client that asks, and deletes on request. +- **Names are sanitised, twice.** The name arrives over the WebSocket and becomes a path on the Pi; + it is reduced to something safe (shared pure function, unit-tested) and the resolved path is then + checked to be inside the projects directory anyway. + +## Capabilities + +### Added Capabilities +- `projects`: a job can be saved and reopened, on the operator's machine or on the plotter. + +### Modified Capabilities +- `gateway-protocol`: commands to save, load and delete stored projects, with the listing in the + snapshot and an event when it changes. + +## Impact + +- **Code:** new `src/plot/project.ts` (+ tests); `src/gateway/protocol.ts`, `gateway/server.ts`, + `src/transport/GatewayClient.ts`, `src/ui/App.tsx`. +- **Packaging:** `PLOTTER_PROJECTS` (default `gateway/projects/`). +- **Behaviour:** the live session is unchanged — projects are a separate thing you *keep*, not a + replacement for the session that is always there. diff --git a/openspec/changes/archive/2026-09-12-projects/specs/gateway-protocol/spec.md b/openspec/changes/archive/2026-09-12-projects/specs/gateway-protocol/spec.md new file mode 100644 index 0000000..337cf06 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/specs/gateway-protocol/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Stored-project channel + +The daemon SHALL accept commands to save, load and delete a stored project. The attach snapshot +SHALL carry the list of stored projects, and the daemon SHALL broadcast the list whenever it +changes. A loaded project SHALL be sent only to the client that asked for it. + +#### Scenario: Listing on attach + +- **WHEN** a client attaches +- **THEN** the snapshot carries the names of the stored projects and when each was saved + +#### Scenario: The list stays current + +- **WHEN** any client saves or deletes a project +- **THEN** every attached client is sent the new list + +#### Scenario: A load is addressed + +- **WHEN** a client asks for a project +- **THEN** only that client receives it diff --git a/openspec/changes/archive/2026-09-12-projects/specs/projects/spec.md b/openspec/changes/archive/2026-09-12-projects/specs/projects/spec.md new file mode 100644 index 0000000..df7f5b7 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/specs/projects/spec.md @@ -0,0 +1,83 @@ +# projects Specification + +## Purpose + +A job can be put aside and picked up again: saved as a file on the operator's machine, or stored on +the plotter so the machine holds a library of plots that any client can open. + +## Requirements + +### Requirement: A project is the whole job + +A saved project SHALL contain everything needed to reproduce the plot — the objects and their +placements, which pen draws each of them, the paper, the job's mode, and the magnets — together +with the pens it refers to. + +#### Scenario: Reopening a project + +- **WHEN** the operator opens a project they saved +- **THEN** the page is restored: the same objects, in the same places, with the same pens, paper + and mode + +#### Scenario: A project opened where its pens are not defined + +- **WHEN** a project is opened on a machine whose pen library does not contain its pens +- **THEN** those pens are added to the library, so the drawing renders as it was saved, and the + pens already defined there are kept + +### Requirement: Projects can be kept on either machine + +The operator SHALL be able to save a project as a file on their own device and open it again, and +to store a project on the plotter, list what is stored, open one, and delete one. + +#### Scenario: Saving to the operator's machine + +- **WHEN** the operator saves the job to their device +- **THEN** a project file is downloaded, and opening that file restores the job + +#### Scenario: Saving to the plotter + +- **WHEN** the operator saves the job to the plotter +- **THEN** it is stored there, appears in the list, and is still there after the daemon restarts + +#### Scenario: Every client sees the library + +- **WHEN** one client saves or deletes a project +- **THEN** the other clients' lists reflect it + +#### Scenario: Opening does not disturb other operators + +- **WHEN** one client opens a stored project +- **THEN** only that client's page changes + +### Requirement: A project file is versioned and validated + +The file SHALL identify itself and carry a format version. Opening something that is not a project, +or a project from a newer version, SHALL be refused with a message that says which. + +#### Scenario: The wrong file + +- **WHEN** the operator opens a JSON file that is not a project +- **THEN** they are told it is not a project file, and the page is unchanged + +#### Scenario: A file from a newer version + +- **WHEN** the file's format version is newer than this app understands +- **THEN** it is refused, rather than opened with whatever that version added silently dropped + +### Requirement: Project names cannot escape the projects directory + +A project name arriving from a client SHALL NOT be usable to read or write outside the directory +projects are stored in. A name with nothing usable left in it SHALL be refused. + +#### Scenario: A name that tries to traverse + +- **WHEN** a project is saved with a name containing path separators or parent references +- **THEN** the file is written inside the projects directory under a sanitised name, and nothing + outside it is touched + +#### Scenario: An empty name + +- **WHEN** a project is saved with a blank name, or one made entirely of characters that are + stripped +- **THEN** the save is refused and the operator is told diff --git a/openspec/changes/archive/2026-09-12-projects/tasks.md b/openspec/changes/archive/2026-09-12-projects/tasks.md new file mode 100644 index 0000000..b0b967c --- /dev/null +++ b/openspec/changes/archive/2026-09-12-projects/tasks.md @@ -0,0 +1,31 @@ +## 1. Format + +- [x] 1.1 `src/plot/project.ts`: `makeProject`, `readProject`, `sanitizeProjectName`, file names +- [x] 1.2 Unit tests: round-trip, pens carried and copied, refusal of non-projects, of newer + versions and of empty sessions, tolerance of missing optional fields, and sanitisation of + traversal, separators, control characters, filesystem-hostile characters and over-long names + +## 2. Daemon + +- [x] 2.1 Projects directory (`PLOTTER_PROJECTS`), atomic save, list, load, delete +- [x] 2.2 Listing in the snapshot and broadcast on change; load sent only to the requester +- [x] 2.3 Resolved-path check inside the projects directory + +## 3. Client + UI + +- [x] 3.1 `saveProject` / `loadProject` / `deleteProject`; `projects` and `projectLoaded` events +- [x] 3.2 Projects panel: name, save to plotter, save to device, open a file, list with delete +- [x] 3.3 Opening merges the project's pens into the library; one shared session builder + +## 4. Docs, gate, verification + +- [x] 4.1 `PLOTTER_PROJECTS` in packaging and the README env table; README and CHANGELOG +- [x] 4.2 `mise run ci` green +- [x] 4.3 Verified against the real daemon: save writes into the projects directory and broadcasts + the listing, another client sees it on attach, load returns the stored project to the asking + client only, `../escaped` lands *inside* the directory under a sanitised name, a nameless + project is refused, a missing one reports it, delete removes and re-broadcasts, and the + library survives a daemon restart +- [x] 4.4 Verified in the browser: save to the plotter, clear the page, reopen from the list and + get the drawing back; opening a project file merges its pens; a stray JSON and a + future-version file are both refused with the right message diff --git a/openspec/specs/gateway-protocol/spec.md b/openspec/specs/gateway-protocol/spec.md index 2994685..e9d0847 100644 --- a/openspec/specs/gateway-protocol/spec.md +++ b/openspec/specs/gateway-protocol/spec.md @@ -151,3 +151,24 @@ count as a running plot for the purposes of refusing an in-app update. - **WHEN** a client requests an in-app update while a program is held at a pen change - **THEN** the daemon refuses it, as it does during a running plot + +### Requirement: Stored-project channel + +The daemon SHALL accept commands to save, load and delete a stored project. The attach snapshot +SHALL carry the list of stored projects, and the daemon SHALL broadcast the list whenever it +changes. A loaded project SHALL be sent only to the client that asked for it. + +#### Scenario: Listing on attach + +- **WHEN** a client attaches +- **THEN** the snapshot carries the names of the stored projects and when each was saved + +#### Scenario: The list stays current + +- **WHEN** any client saves or deletes a project +- **THEN** every attached client is sent the new list + +#### Scenario: A load is addressed + +- **WHEN** a client asks for a project +- **THEN** only that client receives it diff --git a/openspec/specs/projects/spec.md b/openspec/specs/projects/spec.md new file mode 100644 index 0000000..df7f5b7 --- /dev/null +++ b/openspec/specs/projects/spec.md @@ -0,0 +1,83 @@ +# projects Specification + +## Purpose + +A job can be put aside and picked up again: saved as a file on the operator's machine, or stored on +the plotter so the machine holds a library of plots that any client can open. + +## Requirements + +### Requirement: A project is the whole job + +A saved project SHALL contain everything needed to reproduce the plot — the objects and their +placements, which pen draws each of them, the paper, the job's mode, and the magnets — together +with the pens it refers to. + +#### Scenario: Reopening a project + +- **WHEN** the operator opens a project they saved +- **THEN** the page is restored: the same objects, in the same places, with the same pens, paper + and mode + +#### Scenario: A project opened where its pens are not defined + +- **WHEN** a project is opened on a machine whose pen library does not contain its pens +- **THEN** those pens are added to the library, so the drawing renders as it was saved, and the + pens already defined there are kept + +### Requirement: Projects can be kept on either machine + +The operator SHALL be able to save a project as a file on their own device and open it again, and +to store a project on the plotter, list what is stored, open one, and delete one. + +#### Scenario: Saving to the operator's machine + +- **WHEN** the operator saves the job to their device +- **THEN** a project file is downloaded, and opening that file restores the job + +#### Scenario: Saving to the plotter + +- **WHEN** the operator saves the job to the plotter +- **THEN** it is stored there, appears in the list, and is still there after the daemon restarts + +#### Scenario: Every client sees the library + +- **WHEN** one client saves or deletes a project +- **THEN** the other clients' lists reflect it + +#### Scenario: Opening does not disturb other operators + +- **WHEN** one client opens a stored project +- **THEN** only that client's page changes + +### Requirement: A project file is versioned and validated + +The file SHALL identify itself and carry a format version. Opening something that is not a project, +or a project from a newer version, SHALL be refused with a message that says which. + +#### Scenario: The wrong file + +- **WHEN** the operator opens a JSON file that is not a project +- **THEN** they are told it is not a project file, and the page is unchanged + +#### Scenario: A file from a newer version + +- **WHEN** the file's format version is newer than this app understands +- **THEN** it is refused, rather than opened with whatever that version added silently dropped + +### Requirement: Project names cannot escape the projects directory + +A project name arriving from a client SHALL NOT be usable to read or write outside the directory +projects are stored in. A name with nothing usable left in it SHALL be refused. + +#### Scenario: A name that tries to traverse + +- **WHEN** a project is saved with a name containing path separators or parent references +- **THEN** the file is written inside the projects directory under a sanitised name, and nothing + outside it is touched + +#### Scenario: An empty name + +- **WHEN** a project is saved with a blank name, or one made entirely of characters that are + stripped +- **THEN** the save is refused and the operator is told diff --git a/packaging/penplotter271.env b/packaging/penplotter271.env index ea85172..54c4f95 100644 --- a/packaging/penplotter271.env +++ b/packaging/penplotter271.env @@ -29,6 +29,7 @@ GATEWAY_DIST=/opt/penplotter271/dist PLOTTER_STATE=/var/lib/penplotter271/.plotter-state.json PLOTTER_SESSION=/var/lib/penplotter271/.session.json PLOTTER_APP_SETTINGS=/var/lib/penplotter271/.app-settings.json +PLOTTER_PROJECTS=/var/lib/penplotter271/projects # In-app updater: GitHub repo (owner/name) whose latest Release supplies the # update .deb, and where the update oneshot records its progress. diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts index 2317429..8c4cbbf 100644 --- a/src/gateway/protocol.ts +++ b/src/gateway/protocol.ts @@ -55,6 +55,11 @@ export type ClientCommand = // Carry on after a pen change: the operator has loaded the pen the prompt // named. Ignored unless the program is actually held at one. | { cmd: 'continueProgram' } + // Projects stored on the daemon, so the Pi holds a library of plots that any + // client can open. The payload is opaque here, like the session. + | { cmd: 'saveProject'; name: string; project: unknown } + | { cmd: 'loadProject'; name: string } + | { cmd: 'deleteProject'; name: string } // Trigger a self-update to the latest release. Refused while a plot runs. | { cmd: 'update' }; @@ -93,6 +98,15 @@ export interface Snapshot { * it — the job lives on the daemon, not in the tab that started it. */ penChange: { index: number; label: string } | null; + /** Projects stored on the daemon, newest first. */ + projects: ProjectSummary[]; +} + +/** What the client needs to list a stored project without loading it. */ +export interface ProjectSummary { + name: string; + /** ISO timestamp of the last save. */ + savedAt: string; } /** @@ -117,6 +131,10 @@ export interface ForwardedEvents { updateStatus: UpdateStatus; /** The program is held at a pen change; it continues on `continueProgram`. */ penChange: { index: number; label: string }; + /** The stored-project list changed (a save or a delete). */ + projects: ProjectSummary[]; + /** A project the client asked for. Sent only to that client. */ + projectLoaded: { name: string; project: unknown }; /** * Daemon-originated: app settings changed (by another client). Sent to every * client except the one that saved them, so all clients show one setup. diff --git a/src/plot/__tests__/project.test.ts b/src/plot/__tests__/project.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c80795ff455f161db80844464132bd098bf204e GIT binary patch literal 4294 zcmb_gZEq7t5T;-G71Mt4xix1J(N+qDDlI_;fn$+SJ_Ll;cWZme`S#dd$8ppA_dc_? z_llbk2?>zBp4pjsX6Bh$)T+?N(G{iACPvNVh~#A<6E`B|=$ht6uV`?goV0H6_`V9_ z_lZ)y$ulj|=PHvEv63U4t;D&U8vTj&@pby<_~p@`ryrjmzj=Ll+CKUF=*`KC;|U)X zrF{DuBSqS}#)@1y^-pzkX{>TBj>qAKmV5N<==tH>SEnDRN0XB}3Ko?)#O^0Lw~nl| zR_UCcAbbS4vIlgAPw4>-gmHTVy8bYtLKM=xNG}g)pRS==;S>#8!45_g4e7~Kx_M-N=H-Xfh>gk@WaJ!JkkdT~#$$S?+)}R{1Ggek)dp$YAyY!G1gtKl0Z7SG<&yYL zODd<$5?&%GVsjo9T(=YjzQPjCAS&n30Qk_Cu^0I!ptC`S@#ynfWKp~F0*pn>B4-aj z#5wu|86G4{kuPM5Qf#HKtyu{-C`YwYAM4zQ&3Unaj7?-H>un!? z>K!BPI&(19@+V^HP`m!4#bxYF*ZxrY@f zf}%1rpVs*~={cR{>ns})pe{RPM344IG;s3LLHP_Mcy*1xfyuy*wb=(UvyypoSOZdC zZF*^7Q(qv^;5-+&r~aLx{@(Df6|r+JPbWf8DDpejG0P zAItFmCiZgCs7>UA6@a>7rpEpNpFA z(^RHyw&_E2MR6Q+G1epEpdOroj_D`b^XYF_+-~%~3cGg9C0aXbt6|>_NEwCYxz@*@ z1A-nryVKr^E!6V?aj|{NU~0dUOlXD2(!;DzS?1_tex6ydtRY&Pa)?=FK;D0CN(b#to=^TPIPiEL7M3NnM^>aTrPz` z$z$DMnW-wpcN6y>+NHqm z-YBw%)!FSHiudnljjkYkxm_t2Dw6REpOA# z=!~#v8IfQdPWIJs{->I zMPqP`HxWhv4 ({ ...p })), + }; +} + +export type ReadResult = { ok: true; project: ProjectFile } | { ok: false; error: string }; + +/** + * Validate anything claiming to be a project file. Returns a message rather + * than throwing, because every caller here wants to *show* the reason: "that is + * not a project file" is the useful answer to opening the wrong JSON. + */ +export function readProject(raw: unknown): ReadResult { + if (typeof raw !== 'object' || raw === null) { + return { ok: false, error: 'That file is not a PenPlotter271 project.' }; + } + const r = raw as Record; + if (r.format !== PROJECT_FORMAT) { + return { ok: false, error: 'That file is not a PenPlotter271 project.' }; + } + if (typeof r.version !== 'number' || r.version > PROJECT_VERSION) { + return { + ok: false, + error: `That project was saved by a newer version of the app (format ${String(r.version)}).`, + }; + } + if (typeof r.session !== 'object' || r.session === null) { + return { ok: false, error: 'That project file has no drawing in it.' }; + } + return { + ok: true, + project: { + format: PROJECT_FORMAT, + version: r.version, + savedAt: typeof r.savedAt === 'string' ? r.savedAt : '', + name: typeof r.name === 'string' && r.name.trim() ? r.name : 'Untitled', + session: r.session, + pens: Array.isArray(r.pens) ? (r.pens as Pen[]) : [], + }, + }; +} + +/** Longest project name accepted, so a name cannot become an unwieldy filename. */ +const MAX_NAME = 64; + +/** + * Reduce a project name to something safe to use as a filename on the daemon. + * + * This is a security boundary, not a tidiness rule: the name arrives over the + * WebSocket and becomes a path on the Pi, so anything that could climb out of + * the projects directory — separators, `..`, NUL, leading dots — has to be gone + * before it gets near the filesystem. Returns an empty string when nothing + * usable is left, and callers refuse the save. + */ +export function sanitizeProjectName(name: string): string { + return ( + name + .normalize('NFC') + // Control characters are not names, and a NUL truncates a path in some + // syscalls — so they go first, before anything looks at the shape. + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[/\\]/g, ' ') + // Characters that filesystems (or Windows, over a share) refuse outright. + .replace(/[:*?"<>|]/g, '') + .replace(/\s+/g, ' ') + .trim() + // Drop any word that is only dots: with the separators already gone, a + // leftover ".." is harmless, but it is also not part of anyone's name. + .split(' ') + .filter((word) => !/^\.+$/.test(word)) + .join(' ') + // A leading dot hides the file and invites surprises; a trailing one is + // meaningless on some filesystems. + .replace(/^\.+/, '') + .replace(/\.+$/, '') + .slice(0, MAX_NAME) + .trim() + ); +} + +/** The filename a project is stored under. */ +export function projectFileName(name: string): string { + return `${sanitizeProjectName(name)}.plot.json`; +} + +/** The filename offered when downloading to the operator's own machine. */ +export function downloadFileName(name: string): string { + const safe = sanitizeProjectName(name) || 'project'; + return `${safe}.plot.json`; +} diff --git a/src/transport/GatewayClient.ts b/src/transport/GatewayClient.ts index 2fcd4ec..6cd1313 100644 --- a/src/transport/GatewayClient.ts +++ b/src/transport/GatewayClient.ts @@ -1,7 +1,13 @@ import { Emitter } from '../grbl/emitter'; import type { Calibration } from '../grbl/settings'; import type { GrblSettings, StatusReport } from '../grbl/types'; -import type { ClientCommand, ServerMessage, StreamDebug, UpdateStatus } from '../gateway/protocol'; +import type { + ClientCommand, + ProjectSummary, + ServerMessage, + StreamDebug, + UpdateStatus, +} from '../gateway/protocol'; import { normalizeAppSettings, type AppSettings } from '../gateway/appSettings'; type ClientEvents = { @@ -34,6 +40,10 @@ type ClientEvents = { * snapshot too, so a client that attaches mid-job can answer the prompt. */ penChange: { index: number; label: string } | null; + /** Projects stored on the daemon (sent on attach and whenever they change). */ + projects: ProjectSummary[]; + /** A project this client asked for. */ + projectLoaded: { name: string; project: unknown }; }; /** @@ -201,6 +211,7 @@ export class GatewayClient { // Normalised here too: a pre-1.3 daemon sends no field at all. this.events.emit('appSettings', s.appSettings ? normalizeAppSettings(s.appSettings) : null); this.events.emit('penChange', s.penChange ?? null); + this.events.emit('projects', s.projects ?? []); // Continue a plot that was paused by a previous Disconnect-as-pause. if (s.paused) this.resume(); break; @@ -320,6 +331,17 @@ export class GatewayClient { async setSetting(num: number, value: number): Promise { await this.cmd({ cmd: 'setSetting', num, value }); } + /** Store a project on the daemon, so the Pi holds the library. */ + async saveProject(name: string, project: unknown): Promise { + await this.cmd({ cmd: 'saveProject', name, project }); + } + /** Ask for a stored project; it arrives as a `projectLoaded` event. */ + async loadProject(name: string): Promise { + await this.cmd({ cmd: 'loadProject', name }); + } + async deleteProject(name: string): Promise { + await this.cmd({ cmd: 'deleteProject', name }); + } /** Carry on after a pen change, once the operator has loaded the pen. */ async continueProgram(): Promise { await this.cmd({ cmd: 'continueProgram' }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4ab28f9..ad9c195 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -12,6 +12,8 @@ import { imageToField, traceField, type FieldSource } from '../plot/raster'; import { ImportWizard, type ImportSpec } from './ImportWizard'; import { loadPdf, type LoadedPdf } from '../plot/pdf'; import { sniffFile } from '../plot/sniff'; +import { downloadFileName, makeProject, readProject } from '../plot/project'; +import type { ProjectSummary } from '../gateway/protocol'; import { applyDetail } from '../plot/detail'; import { estimatePlotTime, @@ -254,6 +256,9 @@ export function App() { const [useCustomPaper, setUseCustomPaper] = useState(restored?.useCustomPaper ?? false); // A multi-page PDF waiting for the operator to say which page to import. const [pdfPick, setPdfPick] = useState<{ name: string; doc: LoadedPdf } | null>(null); + // Projects stored on the daemon, and the name this job was last saved under. + const [projects, setProjects] = useState([]); + const [projectName, setProjectName] = useState(''); // Import wizard: the image being converted. `artId` is set when reopening an // existing import, so confirming replaces that object instead of adding one. const [importing, setImporting] = useState<{ @@ -364,6 +369,10 @@ export function App() { ackChangedAtRef.current = Date.now(); } }), + ctrl.on('projects', (list) => setProjects(list)), + ctrl.on('projectLoaded', (e) => { + openProject(e.project, e.name); + }), ctrl.on('penChange', (e) => { penChangeRef.current = !!e; setPenChange(e); @@ -449,22 +458,10 @@ export function App() { // Persist the editable session: always to localStorage (instant, offline), and // to the daemon (lives on the Pi, any device) once we've synced its session. useEffect(() => { - const blob: Session = { - items, - // `selectedId` stays in the blob so an older build (or an older daemon's - // stored session) still restores a sensible selection. - selectedId, - selectedIds, - nextId: idRef.current, - paperIdx, - orientation, - useCustomPaper, - customPaper, - paperStyleId, - selectedPenId, - mode, - magnets, - }; + // One builder for the session, shared with project saving: two copies of + // this list would drift, and the field someone forgets to add to the second + // one is a field that quietly vanishes from every saved project. + const blob = currentSession(); saveSession(blob); if (sessionLoadedRef.current) ctrlRef.current?.saveSession(blob); }, [ @@ -1171,6 +1168,108 @@ export function App() { setMagnets((list) => list.map((m) => (m.id === id ? { ...m, x, y } : m))); } + /** Everything that makes up the job, as a project file would store it. */ + function currentSession(): Session { + return { + items, + // `selectedId` stays in the blob so an older build (or an older daemon's + // stored session) still restores a sensible selection. + selectedId, + selectedIds, + nextId: idRef.current, + paperIdx, + orientation, + useCustomPaper, + customPaper, + paperStyleId, + selectedPenId, + mode, + magnets, + }; + } + + /** + * Replace the page with a stored project. The pens it was drawn with come + * with it and are merged into the library: artwork refers to pens by id, so a + * project opened on another machine would otherwise come out in whatever + * pens happen to be defined there. + */ + function openProject(raw: unknown, fallbackName = '') { + const result = readProject(raw); + if (!result.ok) { + setAlert(result.error); + return; + } + const { project } = result; + const s = project.session as Session | null; + if (!s || !Array.isArray(s.items)) { + setAlert('That project file has no drawing in it.'); + return; + } + if (project.pens.length > 0) { + setSettings((prev) => { + const byId = new Map(prev.pens.map((pen) => [pen.id, pen])); + for (const pen of project.pens) if (!byId.has(pen.id)) byId.set(pen.id, pen); + return { ...prev, pens: [...byId.values()] }; + }); + } + // The retained import sources belong to the drawing being replaced. + sourcesRef.current.clear(); + setItems(s.items.map(normalizeArt)); + setSelectedIds(s.selectedIds ?? (s.selectedId ? [s.selectedId] : [])); + if (typeof s.nextId === 'number') idRef.current = Math.max(idRef.current, s.nextId); + if (typeof s.paperIdx === 'number') setPaperIdx(s.paperIdx); + if (s.orientation) setOrientation(s.orientation); + if (typeof s.useCustomPaper === 'boolean') setUseCustomPaper(s.useCustomPaper); + if (s.customPaper) setCustomPaper(s.customPaper); + if (s.paperStyleId) setPaperStyleId(s.paperStyleId); + if (s.selectedPenId) setSelectedPenId(s.selectedPenId); + if (s.mode) setMode(s.mode); + setMagnets(s.magnets ?? []); + setProjectName(project.name || fallbackName); + setAlert(`Opened ${project.name || fallbackName}.`); + } + + /** Save the job to the operator's own machine, as a file they can keep. */ + function downloadProject() { + const name = projectName.trim() || 'Untitled'; + const blob = new Blob([JSON.stringify(makeProject(name, currentSession(), pens), null, 2)], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = downloadFileName(name); + a.click(); + // Revoking immediately can cancel the download in some browsers; a tick is + // enough for the click to have taken the URL. + setTimeout(() => URL.revokeObjectURL(url), 1000); + setAlert(`Saved ${a.download}.`); + } + + async function openProjectFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + try { + openProject(JSON.parse(await file.text()), file.name.replace(/\.plot\.json$/i, '')); + } catch { + setAlert(`Could not read ${file.name} — it is not a project file.`); + } + } + + function saveProjectToPi() { + const name = projectName.trim(); + if (!name) { + setAlert('Give the project a name first.'); + return; + } + void ctrl() + ?.saveProject(name, makeProject(name, currentSession(), pens)) + .then(() => setAlert(`Saved "${name}" on the plotter.`)) + .catch((err) => setAlert(String((err as Error).message ?? err))); + } + function restack(delta: number) { if (plotting || selectedIds.length === 0) return; setItems((list) => reorderMany(list, selectedIds, delta)); @@ -1840,6 +1939,75 @@ export function App() { +
+

+ A project is the whole job — artwork, placement, pens, paper, mode and magnets. + Saved on the plotter it is reachable from any device; saved to this machine it is a + file you keep. +

+ setProjectName(e.target.value)} + /> +
+ + + +
+ {projects.length > 0 && ( +
    + {projects.map((p) => ( +
  • + + +
  • + ))} +
+ )} + {connected && projects.length === 0 && ( +

No projects on the plotter yet.

+ )} +
+

The bed has no vacuum. A magnet the carriage hits at travel speed drags the sheet diff --git a/vite.config.ts b/vite.config.ts index d4f648a..73d7fa7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,10 +26,10 @@ export default defineConfig({ // ratchets against regressions rather than blocking today's work. // Raise these as coverage improves; never lower them to make CI pass. thresholds: { - statements: 50, - branches: 48, - functions: 40, - lines: 50, + statements: 72, + branches: 68, + functions: 62, + lines: 72, }, }, },