Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
113 changes: 111 additions & 2 deletions gateway/server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ProjectSummary[]> {
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<void> {
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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/archive/2026-09-12-projects/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-12
39 changes: 39 additions & 0 deletions openspec/changes/archive/2026-09-12-projects/design.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions openspec/changes/archive/2026-09-12-projects/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading