M9: hosting figcraft-server: wss in the desktop app, server releases, deployment guide (#125)

The desktop app joins wss:// sessions (rustls with webpki roots, certificates always checked, clear errors; FIGCRAFT_CA_FILE for private authorities), with one TLS session shared by the reader and writer threads. figcraft-server stops gracefully on SIGTERM/SIGINT/SIGHUP/Ctrl+C, ships in Linux x86_64/aarch64 release archives, and docs/server.md gains a Deploy on a Linux server guide with a hardened systemd unit, Caddyfile, nginx config and Dockerfile in packaging/server.
This commit is contained in:
BFlat 2026-10-11 15:52:27 +07:00 committed by GitHub
parent a003e3de4f
commit 76df2d4136
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 896 additions and 94 deletions

View File

@ -251,6 +251,49 @@ jobs:
path: dist/release/*
if-no-files-found: error
server:
name: Server Linux ${{ matrix.arch }}
needs: version
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
# with the binary, licences, docs/server.md and the deployment examples. Same runners as the
# app, so the same glibc floor; no craft-fonts (the server draws no text).
# shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli,
# when people host there.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04 }
- { arch: aarch64, runner: ubuntu-22.04-arm }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-server-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Build and package (tar.gz)
run: packaging/server/package.sh
- name: Check the archive
run: |
tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt"
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/server\.md$' '/LICENSE-MIT$'; do
grep -E "$f" "$RUNNER_TEMP/server-contents.txt"
done
ldd target/release/figcraft-server
- uses: actions/upload-artifact@v4
with:
name: release-server-linux-${{ matrix.arch }}
path: dist/release/*.tar.gz
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
@ -460,7 +503,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
runs-on: ubuntu-latest
environment: release
permissions:

120
Cargo.lock generated
View File

@ -914,6 +914,17 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys 0.61.2",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@ -1456,11 +1467,14 @@ dependencies = [
"js-sys",
"log",
"proptest",
"rustls",
"rustls-pki-types",
"serde_json",
"tungstenite",
"wasm-bindgen",
"web-sys",
"web-time",
"webpki-roots",
"windows-registry",
]
@ -1614,6 +1628,7 @@ name = "figcraft-server"
version = "0.3.1"
dependencies = [
"crc32fast",
"ctrlc",
"env_logger",
"figcraft-collab",
"figcraft-collab-net",
@ -1887,6 +1902,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@ -2744,6 +2770,18 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@ -3722,6 +3760,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@ -3784,6 +3836,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@ -4190,6 +4277,12 @@ dependencies = [
"write-fonts",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "svgtypes"
version = "0.16.1"
@ -4560,6 +4653,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@ -4675,6 +4774,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@ -5004,6 +5109,15 @@ dependencies = [
"web-sys",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@ -5851,6 +5965,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"

5
NOTICE
View File

@ -17,5 +17,6 @@ Release builds made with the optional craft-fonts build input (CRAFT_FONTS_DIR)
listed in ATTRIBUTION.md (Inter and others, SIL Open Font License 1.1); each package carries their
licence texts.
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, Zlib, OFL for egui's default
fonts and similar permissive licenses); see each crate's metadata.
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
similar permissive licenses); see each crate's metadata.

View File

@ -140,7 +140,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. See [`docs/server.md`](docs/server.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@ -123,10 +123,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Localization layer, then ja, ko, es, pt-BR, fr, de: 35–55 h.
5. Multiplayer (M9): accounts, teams and TLS in the desktop app on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 45–90 h.
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 45–90 h.
## Progress log
- **2026-10-11** · Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls,
certificates always verified; `FIGCRAFT_CA_FILE` for a private authority), releases ship the server for Linux
x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` walks through deploying it on
a rented Linux server (systemd unit, Caddy or nginx, firewall, Docker, upgrades, backups).
- **2026-10-11** · Plugin panels and settings (gap 6, ABI v2; v1 plugins run unchanged): a plugin shows a panel of
fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step
per callback, same sandbox limits), `plugin.ui` / `plugin.event` / `plugin.close` drive panels from scripts and MCP,
@ -178,6 +182,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |

View File

@ -28,6 +28,8 @@ sha2 = { workspace = true }
crc32fast = { workspace = true }
toml = "1"
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
# Stopping gracefully on SIGTERM/SIGINT/SIGHUP (systemd, Docker, Ctrl+C) and Ctrl+C/close on Windows.
ctrlc = { version = "3", features = ["termination"] }
[dev-dependencies]
figcraft-engine = { workspace = true }

View File

@ -64,7 +64,7 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
cfg.data.display(),
public.url()
);
if addr.ip().is_loopback() {
if addr.ip().is_loopback() && cfg.public_url.is_none() {
log::info!("only this machine can connect: listen on 0.0.0.0:{} (or behind a reverse proxy) to share", addr.port());
}
Ok(Running { hosting })

View File

@ -26,15 +26,29 @@ fn main() -> ExitCode {
};
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).target(env_logger::Target::Stdout).init();
match figcraft_server::start(&cli.config) {
// Serves until the process is stopped: what was confirmed is on disk already, and clients
// reconnect once it runs again.
Ok(_running) => loop {
std::thread::park();
},
let mut running = match figcraft_server::start(&cli.config) {
Ok(r) => r,
Err(e) => {
log::error!("{e}");
ExitCode::FAILURE
return ExitCode::FAILURE;
}
};
// Serves until SIGTERM, SIGINT or SIGHUP (Ctrl+C or closing the console on Windows), then stops
// gracefully: open files are saved and compacted, and everyone is told the server is
// restarting (their apps reconnect once it runs again).
let (stop, stopped) = std::sync::mpsc::channel();
if let Err(e) = ctrlc::set_handler(move || {
let _ = stop.send(());
}) {
// Still safe to kill: what was confirmed is on disk already.
log::warn!("can't watch for stop signals ({e}); stopping won't save open files first");
loop {
std::thread::park();
}
}
let _ = stopped.recv();
log::info!("stopping: saving open files and telling everyone the server is restarting");
running.stop();
log::info!("stopped");
ExitCode::SUCCESS
}

View File

@ -19,6 +19,12 @@ web-time = "1"
# Native transport: blocking tungstenite with a reader and a writer thread per connection.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
# `wss://`: TLS with ring as the crypto provider and Mozilla's root certificates compiled in (no
# OS certificate store, no OpenSSL). Over our own split reader/writer stream (`tls.rs`), so not
# tungstenite's single-owner TLS stream.
rustls = { version = "0.23", default-features = false, features = ["std", "ring", "tls12", "logging"] }
rustls-pki-types = { version = "1", features = ["std"] }
webpki-roots = "1"
getrandom = "0.4"
base64 = { workspace = true }

View File

@ -28,7 +28,7 @@ use crate::invite::{Endpoint, Invite};
pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
use crate::manage;
use crate::net::{Duplex, NetLimits, config, dial, shutdown};
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// What [`Guest::publish`] uploads, and where.
@ -168,8 +168,7 @@ fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published
}
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
let stream = dial(&cfg.invite.server.host, cfg.connect_timeout, &cfg.limits)?;
let (rd, wr, sock) = Duplex::split(stream).map_err(|e| e.to_string())?;
let (rd, wr, sock) = open(&cfg.invite.server, cfg.connect_timeout, &cfg.limits)?;
let (reader, _) = tungstenite::client::client_with_config(cfg.invite.endpoint(), rd, Some(config(cfg.limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft session: {e}", cfg.invite.server.display()))?;
let writer = WebSocket::from_raw_socket(wr, WsRole::Client, Some(config(cfg.limits.max_client_message)));

View File

@ -377,7 +377,7 @@ fn serve(stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, _
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
let Ok((rd, wr, sock)) = Duplex::split(stream) else { return };
let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return };
let cfg = config(limits.max_client_message);
let mut ws = match tungstenite::accept_with_config(rd, Some(config(limits.max_hello))) {
Ok(ws) => ws,

View File

@ -36,6 +36,8 @@ pub mod manage;
#[cfg(not(target_arch = "wasm32"))]
pub mod net;
#[cfg(not(target_arch = "wasm32"))]
pub mod tls;
#[cfg(not(target_arch = "wasm32"))]
pub mod token;
#[cfg(target_arch = "wasm32")]
@ -59,3 +61,5 @@ mod links_tests;
mod loopback_tests;
#[cfg(test)]
mod session_tests;
#[cfg(test)]
mod tls_tests;

View File

@ -12,7 +12,7 @@ use crate::guest::{Guest, GuestConfig, Publish, upload_file};
use crate::host::{Host, HostConfig};
use crate::inbox::{Inbox, Waker};
use crate::invite::{Endpoint, Invite};
use crate::manage::{self, NO_TLS};
use crate::manage;
use crate::net::NetLimits;
use crate::token;
@ -138,9 +138,6 @@ impl LiveLink for NetLink {
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
let invite = Invite::parse(link)?;
if invite.server.secure {
return Err(NO_TLS.into());
}
let cfg = self.guest_config(invite, name);
self.leave();
let inbox = Inbox::new(self.wake.clone());
@ -152,9 +149,6 @@ impl LiveLink for NetLink {
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
let (server, query) = Endpoint::parse(&o.server)?;
if server.secure {
return Err(NO_TLS.into());
}
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
// Until the upload is done the invite names only the server.

View File

@ -97,7 +97,7 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
(host, probe, invite)
}
fn start() -> (Host, Probe, Invite) {
pub(crate) fn start() -> (Host, Probe, Invite) {
start_with(fast_limits(), Limits::default())
}

View File

@ -3,20 +3,15 @@
//! through it, and the server's command line uses it to reach a running server), and using a
//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]).
use std::net::TcpStream;
use std::time::Duration;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, PROTOCOL, ServerMsg};
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::{Endpoint, Invite};
use crate::net::{NetLimits, config, dial};
use crate::net::{Duplex, NetLimits, config, open};
/// Why the desktop app can't reach a `wss://` address (it has no TLS of its own yet).
pub const NO_TLS: &str =
"The desktop app can't open secure (wss://) connections yet: use the server's ws:// address, or join from FigCraft in the browser";
type Ws = WebSocket<TcpStream>;
type Ws = WebSocket<Duplex>;
/// Send `requests` to the server at `server` with its `key`; the replies, in order. Fails when the
/// server can't be reached, refuses the key or closes the connection (a request it can't do is a
@ -67,10 +62,7 @@ pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duratio
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
/// server a while to keep).
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
if server.secure {
return Err(NO_TLS.into());
}
let stream = dial(&server.host, timeout, limits)?;
let (stream, _, _) = open(server, timeout, limits)?;
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;

View File

@ -15,24 +15,28 @@ pub use crate::limits::NetLimits;
use tungstenite::protocol::WebSocketConfig;
use crate::invite::Endpoint;
use crate::tls::{self, Tls};
/// tungstenite's settings for messages up to `max` bytes.
pub fn config(max: usize) -> WebSocketConfig {
WebSocketConfig::default().max_message_size(Some(max)).max_frame_size(Some(max))
}
/// One side (read or write) of a connection: reads from its own handle, writes whole buffers
/// under the connection's write lock.
/// under the connection's write lock; through the connection's TLS session when it has one.
pub struct Duplex {
read: TcpStream,
write: Arc<Mutex<TcpStream>>,
tls: Option<Tls>,
}
impl Duplex {
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
pub fn split(stream: TcpStream) -> io::Result<(Duplex, Duplex, TcpStream)> {
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
let write = Arc::new(Mutex::new(stream.try_clone()?));
let a = Duplex { read: stream.try_clone()?, write: write.clone() };
let b = Duplex { read: stream.try_clone()?, write };
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone() };
let b = Duplex { read: stream.try_clone()?, write, tls };
Ok((a, b, stream))
}
}
@ -47,12 +51,18 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.read.read(buf)
match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
}
}
}
impl Write for Duplex {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if let Some(t) = &self.tls {
return tls::write(t, &self.write, buf);
}
self.write.lock().unwrap_or_else(PoisonError::into_inner).write_all(buf)?;
Ok(buf.len())
}
@ -66,9 +76,17 @@ pub fn shutdown(s: &TcpStream) {
let _ = s.shutdown(Shutdown::Both);
}
/// Connect to `server` (through TLS for `wss://`): the reader's and the writer's handles, and the
/// socket to shut it down with.
pub fn open(server: &Endpoint, timeout: Duration, limits: &NetLimits) -> Result<(Duplex, Duplex, TcpStream), String> {
let stream = dial(&server.host, timeout, limits)?;
let tls = if server.secure { Some(tls::handshake(&stream, &server.host)?) } else { None };
Duplex::split(stream, tls).map_err(|e| e.to_string())
}
/// Connect to `host` (`name:port`), trying each of its addresses within `timeout`, with Nagle off
/// and reads and writes bounded by `limits`' hello and write timeouts.
pub fn dial(host: &str, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
fn dial(host: &str, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
use std::net::ToSocketAddrs;
let addrs = host.to_socket_addrs().map_err(|e| format!("can't find {host}: {e}"))?;
let mut last = format!("can't find {host}");

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,134 @@
//! TLS for `wss://` addresses (native): rustls with ring, trusting Mozilla's root certificates
//! (compiled in) plus any in the PEM file `FIGCRAFT_CA_FILE` names (a private authority or a
//! self-signed server). Certificates are always verified.
//!
//! A connection's reader and writer threads share one TLS session ([`Tls`]), each holding it only
//! to decrypt or encrypt: the reader waits for bytes on its own socket handle without it, and a
//! sender takes the socket's write lock first (so records go out in the order they were made),
//! encrypts under the session's lock, then writes with only the socket's. Neither waits on the
//! network while holding the session, so a send stuck behind a full socket never stops the reader
//! from draining what arrives.
use std::io::{self, Read, Write};
use std::net::TcpStream;
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, ServerName};
use rustls::{CertificateError, ClientConfig, ClientConnection, RootCertStore};
/// The variable naming a PEM file of extra certificate authorities to trust.
pub const CA_FILE: &str = "FIGCRAFT_CA_FILE";
/// One connection's TLS session, shared by its reader and writer.
pub type Tls = Arc<Mutex<ClientConnection>>;
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Set up TLS on `stream`, connected to `host` (`name:port`), verifying its certificate.
pub fn handshake(stream: &TcpStream, host: &str) -> Result<Tls, String> {
let name = host.rsplit_once(':').map_or(host, |(n, _)| n).trim_start_matches('[').trim_end_matches(']');
let name = ServerName::try_from(name.to_string()).map_err(|_| format!("{name} isn't a name a certificate can be for"))?;
let mut conn = ClientConnection::new(config()?, name).map_err(|e| e.to_string())?;
// Writes are flushed to the socket at once, so the queue never grows.
conn.set_buffer_limit(None);
while conn.is_handshaking() {
conn.complete_io(&mut &*stream).map_err(|e| explain(host, &e))?;
}
Ok(Arc::new(Mutex::new(conn)))
}
/// Why the TLS handshake with `host` failed, in words.
fn explain(host: &str, e: &io::Error) -> String {
let why = |c: &CertificateError| match c {
CertificateError::UnknownIssuer => format!(
"isn't signed by an authority this app trusts (for a private or self-signed certificate, set {CA_FILE} to its authority's PEM file)"
),
CertificateError::NotValidForName | CertificateError::NotValidForNameContext { .. } => "is for another name".into(),
CertificateError::Expired | CertificateError::ExpiredContext { .. } => "has expired".into(),
CertificateError::NotValidYet | CertificateError::NotValidYetContext { .. } => "isn't valid yet (is this computer's clock right?)".into(),
CertificateError::Revoked => "has been revoked".into(),
other => format!("isn't valid ({other:?})"),
};
match e.get_ref().and_then(|i| i.downcast_ref::<rustls::Error>()) {
Some(rustls::Error::InvalidCertificate(c)) => format!("the certificate of {host} {}", why(c)),
Some(other) => format!("no secure connection to {host} ({other}); is a TLS server answering there?"),
None => format!("no secure connection to {host} ({e})"),
}
}
/// The client settings, made once.
fn config() -> Result<Arc<ClientConfig>, String> {
static CONFIG: OnceLock<Result<Arc<ClientConfig>, String>> = OnceLock::new();
CONFIG.get_or_init(make_config).clone()
}
fn make_config() -> Result<Arc<ClientConfig>, String> {
let mut roots = RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
if let Some(path) = std::env::var_os(CA_FILE) {
let shown = std::path::Path::new(&path).display().to_string();
let certs: Vec<CertificateDer> = CertificateDer::pem_file_iter(&path)
.and_then(Iterator::collect)
.map_err(|e| format!("can't read the certificates in {CA_FILE} ({shown}): {e}"))?;
if roots.add_parsable_certificates(certs).0 == 0 {
return Err(format!("{CA_FILE} ({shown}) has no certificate this app can use"));
}
}
#[cfg(test)]
roots.add(CertificateDer::from(&include_bytes!("testdata/test-ca.der")[..])).map_err(|e| e.to_string())?;
let config = ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(Arc::new(config))
}
/// Read plaintext into `buf`: what the session has, else wait for bytes on `sock` (outside the
/// session's lock), take them in and try again. Anything the session must answer (alerts, key
/// updates) goes out through `out`.
pub fn read(tls: &Mutex<ClientConnection>, sock: &mut TcpStream, out: &Mutex<TcpStream>, buf: &mut [u8]) -> io::Result<usize> {
loop {
match lock(tls).reader().read(buf) {
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
done => return done,
}
// Wait for ciphertext without taking it off the socket, then let the session read only
// what it takes: its plaintext buffer is empty here, so it never overflows (feeding it a
// whole chunk could, when a record completes on top of plaintext not yet read).
sock.peek(&mut [0u8; 1])?;
let answer = {
let mut c = lock(tls);
// An empty read (the socket ended) tells the session so: the next read ends too.
c.read_tls(sock)?;
c.process_new_packets().map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
c.wants_write()
};
if answer {
send(tls, out, &[])?;
}
}
}
/// Encrypt and send all of `buf`.
pub fn write(tls: &Mutex<ClientConnection>, out: &Mutex<TcpStream>, buf: &[u8]) -> io::Result<usize> {
send(tls, out, buf)?;
Ok(buf.len())
}
/// Encrypt `plain` and send it after whatever the session has waiting (see the module's docs for
/// the locking).
fn send(tls: &Mutex<ClientConnection>, out: &Mutex<TcpStream>, plain: &[u8]) -> io::Result<()> {
let mut w = lock(out);
let mut records = Vec::new();
{
let mut c = lock(tls);
c.writer().write_all(plain)?;
while c.wants_write() {
c.write_tls(&mut records)?;
}
}
w.write_all(&records)
}

View File

@ -0,0 +1,124 @@
//! Joining over `wss://`: a guest and a managing connection reach a host through a TLS terminator
//! (like the reverse proxy in front of a server) whose certificate comes from a test authority
//! only test builds trust; a certificate for another name is refused with a clear reason.
use std::io::{self, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::time::Duration;
use figcraft_engine::live::{Event, Status};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use rustls::{ServerConfig, ServerConnection, StreamOwned};
use crate::guest::{Guest, GuestConfig};
use crate::invite::{Endpoint, Invite};
use crate::loopback_tests::{Probe, fast_limits, quick, start};
use crate::manage;
/// Serve `localhost`'s test certificate on a port of `localhost` and relay each connection to
/// `upstream`.
fn terminator(upstream: SocketAddr) -> SocketAddr {
let cert = CertificateDer::from(&include_bytes!("testdata/localhost.der")[..]).into_owned();
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(include_bytes!("testdata/localhost-key.der").to_vec()));
let cfg = ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert], key)
.unwrap();
let cfg = Arc::new(cfg);
// The name's first address, which a client tries first (Windows takes seconds to give up on
// a refused one).
let listener = TcpListener::bind("localhost:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for s in listener.incoming().flatten() {
let cfg = cfg.clone();
std::thread::spawn(move || relay(s, upstream, cfg));
}
});
addr
}
/// Copy bytes both ways until either side ends (polling with short timeouts: test code).
fn relay(s: TcpStream, upstream: SocketAddr, cfg: Arc<ServerConfig>) {
let tick = Some(Duration::from_millis(2));
let Ok(mut up) = TcpStream::connect(upstream) else { return };
up.set_read_timeout(tick).unwrap();
s.set_read_timeout(tick).unwrap();
let mut tls = StreamOwned::new(ServerConnection::new(cfg).unwrap(), s);
let mut buf = [0u8; 16 * 1024];
let pass = |r: io::Result<usize>, to: &mut dyn Write, buf: &[u8]| match r {
Ok(0) => false,
Ok(n) => to.write_all(&buf[..n]).and_then(|()| to.flush()).is_ok(),
Err(e) => matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut),
};
loop {
let r = tls.read(&mut buf);
if !pass(r, &mut up, &buf) {
return;
}
let r = up.read(&mut buf);
if !pass(r, &mut tls, &buf) {
return;
}
}
}
fn secure(host: String, invite: &Invite) -> Invite {
Invite { server: Endpoint { host, path: "/".into(), secure: true }, ..invite.clone() }
}
#[test]
fn guests_and_managing_connections_join_over_wss() {
let (mut host, _hp, invite) = start();
let front = terminator(host.addr());
let wss = secure(format!("localhost:{}", front.port()), &invite);
assert!(wss.url().starts_with("wss://localhost:"));
let mut probe = Probe::new();
let mut g = Guest::start(quick(GuestConfig::new(wss.clone(), "Ada".into())), probe.inbox.clone()).unwrap();
probe.wait("the welcome over TLS", |e| e.iter().any(|e| matches!(e, Event::Welcome { .. })));
// The managing path speaks TLS too: an app's host answers, and refuses to manage files.
let err = manage::request(&wss.server, "key", Vec::new(), &fast_limits(), Duration::from_secs(5)).unwrap_err();
assert!(err.contains("hosted by an app"), "{err}");
g.leave();
host.stop();
}
#[test]
fn a_certificate_for_another_name_is_refused() {
let (mut host, _hp, invite) = start();
let front = terminator(host.addr());
let mut probe = Probe::new();
let _g = Guest::start(quick(GuestConfig::new(secure(front.to_string(), &invite), "Ada".into())), probe.inbox.clone()).unwrap();
probe.wait("the refusal", |e| {
e.iter()
.any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains(&format!("certificate of {front} is for another name"))))
});
host.stop();
}
#[test]
fn reader_and_writer_share_one_session_without_blocking_each_other() {
// An echo server behind the terminator: more than any socket buffers hold, written in one go,
// comes back while it is still being sent (were the reader to wait for the writer, both would
// stall once the buffers fill).
let echo = TcpListener::bind("127.0.0.1:0").unwrap();
let upstream = echo.local_addr().unwrap();
std::thread::spawn(move || {
let (mut s, _) = echo.accept().unwrap();
let _ = io::copy(&mut s.try_clone().unwrap(), &mut s);
});
let front = terminator(upstream);
let server = Endpoint { host: format!("localhost:{}", front.port()), path: "/".into(), secure: true };
let (mut rd, mut wr, _sock) = crate::net::open(&server, Duration::from_secs(5), &fast_limits()).unwrap();
rd.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let sent: Vec<u8> = (0..8 << 20).map(|i: u32| (i % 251) as u8).collect();
let expect = sent.clone();
let writer = std::thread::spawn(move || wr.write_all(&sent).unwrap());
let mut got = vec![0u8; expect.len()];
rd.read_exact(&mut got).unwrap();
writer.join().unwrap();
assert!(got == expect);
}

View File

@ -461,8 +461,10 @@ resuming with its offline edits, and leaving the session going back to snapshot
joins.
- **Links** name the server's endpoint: `figcraft://join?host=<host:port>&file=…&token=…` for a
plain `ws://` endpoint at `/`, `figcraft://join?server=<ws(s)://host[:port][/path]>&file=…&token=…`
for any endpoint (TLS, a path behind a proxy), or `ws(s)://…?file=…&token=…`. The desktop app
can't open `wss://` yet (no TLS of its own); the browser can.
for any endpoint (TLS, a path behind a proxy), or `ws(s)://…?file=…&token=…`. The desktop app,
`figcraft-cli` and the browser all open `wss://`: natively through rustls (ring, Mozilla's root
certificates compiled in, plus any authority in the PEM file `FIGCRAFT_CA_FILE` names), always
verifying the certificate; the browser through its own WebSocket.
- **Managing files.** A connection that opens with `Manage {key}` instead of `Hello` sends
`Admin` requests: publish a document (its snapshot) as a new file, which returns an edit link
and a view link; list files and links; make and revoke links; remove a file. The server key is
@ -644,7 +646,9 @@ chooses to share on the network; the default is the invite link with a random to
- **Abuse limits.** On top of the transport's: at most `max_files` files, `max_file_mb` per file
(a publish over it is refused), `max_links` links per file, `max_people` per file and
`max_connections` sockets.
- **TLS.** None in the server: a reverse proxy terminates it. Tokens and keys are never in the
- **TLS.** None in the server: a reverse proxy terminates it (Caddy and nginx examples in
`packaging/server/`, deployment in [`server.md`](server.md#deploy-on-a-linux-server)); clients
speak `wss://` to the proxy. Tokens and keys are never in the
WebSocket request (only in the first message), so proxies don't log them, and the server logs
file and link ids, never secrets.
- **Not yet**: accounts and per-person permissions, per-address rate limits, audit logs,
@ -672,5 +676,4 @@ chooses to share on the network; the default is the invite link with a random to
5. **Standalone `figcraft-server`** with persistence, compaction, share links and permissions.
**Done** (`apps/figcraft-server` on the shared hub; edit and view links, the server key,
publishing from the app; see [`server.md`](server.md)).
6. **Accounts**: people signing in, per-person permissions, teams and projects; TLS in the
desktop app.
6. **Accounts**: people signing in, per-person permissions, teams and projects.

View File

@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
@ -12,7 +12,7 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr
| Feature | Figma | FigCraft | Hours |
|---|---|---|---|
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (Share ▸ Publish to server…). Left: TLS in the desktop app (browsers use `wss://` through a reverse proxy), packaging the server | 25–55 |
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (Share ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker). Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **partial, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; no spotlight | 8–15 |
| Cursor chat, audio, high-fives | yes | no | 4–8 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. No accounts, teams or projects | 8–17 |
@ -26,6 +26,7 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Team libraries on `figcraft-server` (gap 10): a file's library is published to the server under the file and enabled in other files by a share link, updates are noticed in the background and accepted with overrides kept, the server counts which files use which components, and a library can be swapped for another by names; team libraries 10–20 h → 4–8 h; ready ~12% → ~13% |
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |

View File

@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@ -56,8 +56,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
### 2. Multiplayer and sharing are a preview, without accounts
- **Missing:** accounts, teams and projects, per-person permissions, spotlight, cursor chat, audio,
shared comments and version history, TLS in the desktop app. Comments and versions are local to
the file.
shared comments and version history. Comments and versions are local to the file.
- **Evidence:** the core landed in #103 (`crates/collab`: client-scoped ids, change sets, server
ordering and validation, client reconciliation, binary messages, randomized convergence tests over
real commands), and live sessions run over the network (`crates/collab-net`: hosting from the
@ -67,13 +66,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
styles, variables and comments that several people create at once all kept; a standalone
`figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing
confirmed), takes files published from the app and hands out share links that let people edit
or only view (`docs/server.md`). But there are no accounts (a link is the access), and the
desktop app speaks only plain `ws://` (TLS comes from a reverse proxy, which only the browser
can use so far) (`docs/collab-protocol.md`).
or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse
proxy, and releases ship it with a deployment guide (`docs/server.md`). But there are no
accounts (a link is the access) (`docs/collab-protocol.md`).
- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work
without it.
- **Estimate:** 45–90 h: accounts, teams and per-person permissions, TLS in the desktop app,
shared comments and versions (numbers from before the server landed; re-measured next).
- **Estimate:** 45–90 h: accounts, teams and per-person permissions, shared comments and versions
(numbers from before the server landed; re-measured next).
### 3. Nothing checked against real Figma files
- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and
@ -278,6 +277,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |

View File

@ -649,8 +649,9 @@ joined file opens as a new document).
- `collab.join {link}` (any form: an app's invite, a `figcraft-server` share link, or the
`ws(s)://…?file=…&token=…` URL) returns at once with `status: "connecting"`; poll
`collab.peers` until `status` is `"live"`, or `"failed"` with a `reason` (a wrong token, a full
session, a revoked link, an unreachable host). `collab.leave {}` leaves; the synced document
stays open as your copy.
session, a revoked link, an unreachable host, a `wss://` server whose certificate isn't trusted:
see `FIGCRAFT_CA_FILE` in [`server.md`](server.md#behind-a-reverse-proxy-tls)). `collab.leave {}`
leaves; the synced document stays open as your copy.
- `collab.publish {server, name?, key?}` puts the active document on a `figcraft-server`
([`server.md`](server.md)) and syncs it from there: `server` is the address
`figcraft-server key` prints (`ws://host:47900/?key=…`; or give `key` separately), `name` the

View File

@ -1,9 +1,9 @@
# Releasing FigCraft
Every push to the `release` branch runs `.github/workflows/release.yml`. The workflow builds
installers for macOS, Windows, Linux and the web, plus a FreeBSD tarball, then creates or updates
a **draft** GitHub Release named `FigCraft v<version>`. Nobody sees a draft until a maintainer
publishes it.
installers for macOS, Windows, Linux and the web, plus a FreeBSD tarball and the collaboration
server for Linux, then creates or updates a **draft** GitHub Release named `FigCraft v<version>`.
Nobody sees a draft until a maintainer publishes it.
This is FigCraft's copy of the shared [release playbook](release-playbook.md) (PhotoCraft is the
reference implementation). User-facing names say **FigCraft**. Files, binaries and ids stay
@ -47,9 +47,9 @@ Pushing to `release` again before publishing rebuilds the same draft and replace
After the draft is published, the workflow refuses to touch that version again, so bump first.
**Dry runs:** `gh workflow run release.yml --ref <branch> -f version=0.2.0-rc.1` builds every
unsigned platform (Linux, Flatpak, FreeBSD, Web) on any branch. Only the macOS, Windows and
draft-release jobs declare `environment: release`; off the `release` branch the environment
refuses them (0 steps) and no release is created. The `version` input is applied with
unsigned platform (Linux, Flatpak, FreeBSD, Web, the server) on any branch. Only the macOS,
Windows and draft-release jobs declare `environment: release`; off the `release` branch the
environment refuses them (0 steps) and no release is created. The `version` input is applied with
`xtask version set` before building, so the binaries report it too.
## What gets built
@ -62,6 +62,7 @@ refuses them (0 steps) and no release is created. The `version` input is applied
| Linux riscv64 | `figcraft-<v>-linux-riscv64.tar.gz` (CLI smoke-tested under QEMU; needs glibc ≥ 2.39) | cross-compiled on `ubuntu-24.04` against ports.ubuntu.com riscv64 libraries |
| FreeBSD 14 x86_64 | `figcraft-<v>-freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM on `ubuntu-latest` |
| Web | `figcraft-web-<v>.zip` (static site; [`packaging/web/README.md`](../packaging/web/README.md)) | `ubuntu-latest` |
| Server, Linux x86_64 / aarch64 | `figcraft-server-<v>-linux-<arch>.tar.gz` (the collaboration server; [`server.md`](server.md)) | `ubuntu-22.04` / `ubuntu-22.04-arm` |
`figcraft-cli` ships in every desktop package (and its own macOS zip) when `apps/figcraft-cli`
is in the workspace: `packaging/env.sh` sets `BINS`/`HAS_CLI` from that, and `package.ps1`,
@ -71,12 +72,12 @@ is in the workspace: `packaging/env.sh` sets `BINS`/`HAS_CLI` from that, and `pa
read by `apps/figcraft/build.rs`).
**The collaboration server** (`apps/figcraft-server`, binary `figcraft-server`, see
[`server.md`](server.md)) is in the workspace but not packaged yet: releases don't ship it, and
operators build it with `cargo build --release -p figcraft-server`. Shipping it means a
`figcraft-server-<v>-<platform>-<arch>` archive per desktop platform (a headless binary: no
bundle, installer entry or desktop file), signed like `figcraft-cli` on macOS and Windows; that
is a change to `release.yml` and `packaging/` of its own, not part of `BINS` (which every
desktop package installs).
[`server.md`](server.md)) is built by the `server` job with `packaging/server/package.sh`: a
headless binary, so not part of `BINS` (which every desktop package installs) and no installer,
desktop file or fonts. Its archive holds the binary, the licences, `server.md` and the deployment
examples from `packaging/server/` (systemd unit, Caddyfile, nginx; the Dockerfile builds from
the sources). Linux only for now; elsewhere operators build it with
`cargo build --release -p figcraft-server`.
**Fonts.** Every build job checks out [craft-fonts](https://github.com/storytold/craft-fonts) at
`CRAFT_FONTS_REF` (top of `release.yml`) and builds with `CRAFT_FONTS_DIR` and

View File

@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@ -17,7 +17,7 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
40–60 h, parallel by tool.
4. **Localization layer**, then Figma's own languages first (ja, ko, es, pt-BR, fr, de): 15–25 h
for the layer, 3–5 h per language plus native review.
5. **Multiplayer M9**: accounts, teams and TLS in the desktop app on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
## Alpha gate
@ -55,7 +55,7 @@ blockers.
| M6 Interop & Dev Mode | ~55% (code connect and Dev Mode plugins landed 2026-10-11) | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 37–75 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library | accounts, teams, TLS in the desktop app, packaging the server | 45–90 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 |
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2) | distribution, widgets; Figma plugin compatibility is an owner decision | 15–40 |
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
@ -72,6 +72,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |

View File

@ -20,6 +20,10 @@ cargo build --release -p figcraft-server
target/release/figcraft-server --listen 0.0.0.0:47900 --data /srv/figcraft
```
Releases also ship it ready-built for Linux (`figcraft-server-<version>-linux-x86_64.tar.gz` and
`-linux-aarch64`). To run it for others on a rented server, see
[Deploy on a Linux server](#deploy-on-a-linux-server).
- `--listen` defaults to `127.0.0.1:47900`: only this machine can connect. `0.0.0.0:47900` (or an
interface's address) lets the network in; put it behind a TLS reverse proxy before you expose
it beyond a trusted network (see below).
@ -27,10 +31,12 @@ target/release/figcraft-server --listen 0.0.0.0:47900 --data /srv/figcraft
time uses a directory: a second one refuses to start.
- Logs go to standard output (`RUST_LOG=debug` for more). They name files, link ids and addresses,
never keys or tokens.
- Stop it like any service (Ctrl+C, `systemctl stop`, `kill`). Every change it accepted is on disk
before anyone is told about it, so nothing confirmed is lost; a file's log is folded into a new
snapshot the next time everyone leaves it. Apps that were in a file keep editing and reconnect
once the server is back (for about a minute and a half), as the same participants.
- Stop it like any service (Ctrl+C, `systemctl stop`, `docker stop`, `kill`): on SIGTERM, SIGINT
or SIGHUP (Ctrl+C or closing the console on Windows) it saves and compacts the open files and
tells everyone it is restarting. Every change it accepted is on disk before anyone is told about
it, so even a hard kill loses nothing confirmed (a file's log is then folded into a new snapshot
the next time everyone leaves it). Apps that were in a file keep editing and reconnect once the
server is back (for about a minute and a half), as the same participants.
## Publishing a file
@ -169,27 +175,164 @@ the server still knows them, else they are told what couldn't be kept).
## Behind a reverse proxy (TLS)
The server speaks plain WebSocket (`ws://`); TLS comes from a reverse proxy in front of it, which
gives browsers the `wss://` they need on an `https://` page.
gives people `wss://` links (and browsers the `wss://` they need on an `https://` page).
- Run the server on the loopback interface (`listen = "127.0.0.1:47900"`), and set
`public_url` to the address people use, for example `wss://figcraft.example.com/`. Links and
the publish address then name the proxy.
- **Caddy**: a site block for your host name whose only directive is a `reverse_proxy` to
`127.0.0.1:47900`. Caddy obtains the certificate and passes WebSocket upgrades through on its
own.
- **nginx**: a `server` block listening on 443 with your certificate, and a `location` that
passes requests to `http://127.0.0.1:47900` over HTTP/1.1 with the `Upgrade` and `Connection`
request headers forwarded (that is what lets the WebSocket upgrade through). Raise the proxy's
read timeout above the server's 45-second idle limit or leave its default (60 s): apps ping
every 15 seconds.
- **Caddy** ([`packaging/server/Caddyfile`](../packaging/server/Caddyfile)): a site block for
your host name with a `reverse_proxy` to `127.0.0.1:47900`. Caddy obtains the certificate and
passes WebSocket upgrades through on its own.
- **nginx** ([`packaging/server/nginx.conf`](../packaging/server/nginx.conf)): a `server` block
on 443 with your certificate that passes requests to `http://127.0.0.1:47900` over HTTP/1.1
with the `Upgrade` and `Connection` headers forwarded (that is what lets the WebSocket upgrade
through), and a read timeout above the server's 45-second idle limit (apps ping every 15
seconds).
- **Under a path**: the server answers on any path, so the proxy can forward a prefix such as
`/figcraft/` unchanged; set `public_url = "wss://example.com/figcraft/"`.
- Tokens never travel in the WebSocket request (the client sends them inside its first message),
so the proxy's access logs don't record them.
The desktop app has no TLS of its own yet: it can't open `wss://` links. Inside a trusted network
or a VPN, let it reach the server's `ws://` address directly (keep the port closed to the
internet), and use `wss://` links from the browser build.
The desktop app, `figcraft-cli` (and so agents through MCP) and the browser build all open
`wss://` links. The desktop app and the CLI check the certificate against Mozilla's list of root
authorities built into them (not the operating system's store), so a certificate from a public
authority such as Let's Encrypt just works. For a certificate from a private authority (or a
self-signed one, such as Caddy's `tls internal`), set the environment variable `FIGCRAFT_CA_FILE`
to that authority's certificate (a PEM file) before starting the app: it is trusted in addition
to the built-in list. There is no way to skip the check: a certificate that has expired, is for
another name or comes from an unknown authority is refused, and the app says which.
## Deploy on a Linux server
Hosting for people outside your network: a small Linux server (1 CPU and 1 GB of memory are
plenty for a team), a DNS name pointing at it (`figcraft.example.com` below: use yours), the
server behind Caddy or nginx for TLS, and systemd keeping it running. The commands are for Debian
and Ubuntu; other distributions differ only in how packages are installed.
**1. Install the binary**, from the latest release (x86_64 or aarch64), checked against the
release's `SHA256SUMS.txt`:
```sh
VERSION=$(curl -fsSL https://api.github.com/repos/storytold/figcraft/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
NAME="figcraft-server-$VERSION-linux-$(uname -m)"
curl -fLO "https://github.com/storytold/figcraft/releases/download/v$VERSION/$NAME.tar.gz"
curl -fLO "https://github.com/storytold/figcraft/releases/download/v$VERSION/SHA256SUMS.txt"
sha256sum --ignore-missing -c SHA256SUMS.txt
tar -xzf "$NAME.tar.gz" && cd "$NAME"
sudo install -m 755 figcraft-server /usr/local/bin/
```
or from source (it needs a C compiler for the TLS library; the example files are then in
`packaging/server/`):
```sh
sudo apt install -y build-essential git curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && . "$HOME/.cargo/env"
git clone https://github.com/storytold/figcraft && cd figcraft
cargo build --release --locked -p figcraft-server
sudo install -m 755 target/release/figcraft-server /usr/local/bin/
cd packaging/server
```
**2. A user of its own, its data directory and its settings:**
```sh
sudo useradd --system --home-dir /var/lib/figcraft-server --shell /usr/sbin/nologin figcraft
sudo install -d -o figcraft -g figcraft -m 700 /var/lib/figcraft-server
sudo install -d -m 755 /etc/figcraft-server
sudo tee /etc/figcraft-server/server.toml >/dev/null <<'EOF'
listen = "127.0.0.1:47900"
data = "/var/lib/figcraft-server"
public_url = "wss://figcraft.example.com/"
EOF
```
**3. Run it with systemd.** [`figcraft-server.service`](../packaging/server/figcraft-server.service)
runs it as that user, restarts it if it fails, stops it gracefully, and locks it down: no new
privileges, a read-only system with only the data directory writable, no capabilities, private
`/tmp` and devices, and a system-call filter.
```sh
sudo cp figcraft-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now figcraft-server
systemctl status figcraft-server # logs: journalctl -u figcraft-server
```
**4. TLS in front of it.** Point the DNS name's A (and AAAA) record at the server first, then set
`HOST` to it. Either **Caddy** (simplest: it gets and renews the certificate itself; packaged in
Debian 12+ and Ubuntu 24.04+):
```sh
HOST=figcraft.example.com
sudo apt install -y caddy
sed "s/figcraft.example.com/$HOST/" Caddyfile | sudo tee /etc/caddy/Caddyfile >/dev/null
sudo systemctl reload caddy
```
or **nginx** with a certificate from Let's Encrypt (certbot renews it):
```sh
HOST=figcraft.example.com
sudo apt install -y nginx certbot python3-certbot-nginx
sudo certbot certonly --nginx -d "$HOST"
sed "s/figcraft.example.com/$HOST/g" nginx.conf | sudo tee /etc/nginx/conf.d/figcraft.conf >/dev/null
sudo nginx -t && sudo systemctl reload nginx
```
**5. Firewall: only SSH, 80 and 443.** Port 80 answers certificate challenges and redirects to
https; the server's own port stays closed (it listens on the loopback interface anyway).
```sh
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
**6. Publish and share.** Print the publish address (`wss://figcraft.example.com/?key=…`) and use
it in the app's **Share ▸ Publish to server…** (see [Publishing a file](#publishing-a-file)):
```sh
sudo -u figcraft figcraft-server --config /etc/figcraft-server/server.toml key
```
The other commands ([Managing files and links](#managing-files-and-links)) run the same way, as
the `figcraft` user with `--config /etc/figcraft-server/server.toml`.
**In Docker instead of systemd.** [`packaging/server/Dockerfile`](../packaging/server/Dockerfile)
builds a small image (the binary on a distroless base, no shell) that runs as an unprivileged
user, keeps its files in the `/data` volume and stops gracefully on `docker stop`. Build it from
the repository root, give it a settings file with your `public_url`, and publish its port on the
loopback interface only (ports Docker publishes bypass `ufw`), with the proxy from step 4 in front:
```sh
docker build -f packaging/server/Dockerfile -t figcraft-server .
sudo install -d /etc/figcraft-server
printf 'listen = "0.0.0.0:47900"\ndata = "/data"\npublic_url = "wss://figcraft.example.com/"\n' | sudo tee /etc/figcraft-server/server.toml
docker run -d --name figcraft-server --restart unless-stopped -p 127.0.0.1:47900:47900 \
-v figcraft-data:/data -v /etc/figcraft-server/server.toml:/etc/figcraft-server/server.toml:ro figcraft-server
docker exec figcraft-server /usr/local/bin/figcraft-server --config /etc/figcraft-server/server.toml key
```
**Upgrading.** Install the new binary as in step 1, then restart: the restart is graceful and apps
reconnect on their own. An app speaking another protocol version is refused with a message saying
so: upgrade apps and server together when the release notes say the protocol changed.
```sh
sudo systemctl restart figcraft-server
```
With Docker: rebuild the image, then `docker rm -f figcraft-server` and the `docker run` above
again (the volume keeps the files).
**Backups.** See [Backups](#backups); with systemd, for example:
```sh
sudo systemctl stop figcraft-server
sudo tar -C /var/lib -czf "figcraft-backup-$(date +%F).tar.gz" figcraft-server
sudo systemctl start figcraft-server
```
## Security
@ -199,7 +342,7 @@ internet), and use `wss://` links from the browser build.
refused by the server whatever the client does.
- **Transport**: plain `ws://` on the server's port; anyone on the network path can read traffic
(presence, changes, tokens) unless TLS terminates in front of it. Use a reverse proxy for
anything beyond a trusted network.
anything beyond a trusted network ([Deploy on a Linux server](#deploy-on-a-linux-server)).
- **Limits**: the transport's (message sizes, 100 messages a second per connection, idle and
handshake timeouts, `max_connections`) and the collaboration core's (layers, changes per batch,
`max_people`), plus `max_files`, `max_file_mb` and `max_links`. Every message is decoded

View File

@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@ -110,7 +110,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
| 15 | Dev Mode / inspect | 2 | 90% | 64% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 |
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 |
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat ([collaboration-parity](collaboration-parity.md)) | 45–90 |
| 18 | Plugins & widgets | 4 | 45% | 28% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples), then plugin panels called back per button or changed value and per-plugin settings (ABI v2). Left: distribution, widgets; panels one at a time without a colour picker; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 15–40 |
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 |
@ -580,6 +580,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |

View File

@ -0,0 +1,16 @@
# Caddy in front of figcraft-server; setup in docs/server.md ("Deploy on a Linux server").
# Replace the host name with yours (its DNS must point at this machine). Caddy obtains and renews
# the certificate and passes WebSocket upgrades through by itself. Put this in /etc/caddy/Caddyfile
# and run `sudo systemctl reload caddy`.
figcraft.example.com {
reverse_proxy 127.0.0.1:47900
}
# Under a path of an existing site instead (then public_url = "wss://example.com/figcraft/"):
#
# example.com {
# handle /figcraft/* {
# reverse_proxy 127.0.0.1:47900
# }
# }

View File

@ -0,0 +1,26 @@
# figcraft-server in a container; setup in docs/server.md ("Deploy on a Linux server"). Build from
# the repository root:
#
# docker build -f packaging/server/Dockerfile -t figcraft-server .
#
# The image runs as an unprivileged user, keeps its files in the /data volume and reads
# /etc/figcraft-server/server.toml (listen on 0.0.0.0:47900, data in /data; mount your own to set
# public_url). `docker stop` sends SIGTERM: the server saves open files and stops gracefully.
FROM rust:1-bookworm AS build
WORKDIR /src
COPY . .
RUN cargo build --release --locked -p figcraft-server \
&& mkdir -p /out/data \
&& printf 'listen = "0.0.0.0:47900"\ndata = "/data"\n' > /out/server.toml
# glibc and libgcc only, no shell; "nonroot" is uid 65532.
FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=build /src/target/release/figcraft-server /usr/local/bin/figcraft-server
COPY --from=build /out/server.toml /etc/figcraft-server/server.toml
COPY --from=build --chown=nonroot:nonroot /out/data /data
USER nonroot
VOLUME /data
EXPOSE 47900
STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/local/bin/figcraft-server", "--config", "/etc/figcraft-server/server.toml"]

View File

@ -0,0 +1,9 @@
# The build context is the repository root: send only what the build needs.
.git
target
target-*
dist
plan
plans
corpus
log

View File

@ -0,0 +1,56 @@
# systemd unit for figcraft-server; setup in docs/server.md ("Deploy on a Linux server").
# Install: sudo cp figcraft-server.service /etc/systemd/system/ && sudo systemctl daemon-reload
# sudo systemctl enable --now figcraft-server
# Logs: journalctl -u figcraft-server
[Unit]
Description=FigCraft collaboration server
Documentation=https://github.com/storytold/figcraft/blob/main/docs/server.md
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=figcraft
Group=figcraft
ExecStart=/usr/local/bin/figcraft-server --config /etc/figcraft-server/server.toml
# SIGTERM (systemctl stop/restart) stops it gracefully: open files are saved and compacted, and
# apps are told the server is restarting (they reconnect when it is back).
KillSignal=SIGTERM
TimeoutStopSec=30
Restart=on-failure
RestartSec=2
# max_connections (512 by default) plus the files kept open.
LimitNOFILE=4096
UMask=0077
# Hardening: the only writable place is the data directory.
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/figcraft-server
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RemoveIPC=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,40 @@
# nginx in front of figcraft-server; setup in docs/server.md ("Deploy on a Linux server").
# Put this in /etc/nginx/conf.d/figcraft.conf (it goes inside nginx's http block), replace the
# host name and the certificate paths (these are certbot's), then `sudo nginx -t` and
# `sudo systemctl reload nginx`.
# WebSocket upgrades: forward the client's Upgrade header and ask for the upgrade only then.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name figcraft.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name figcraft.example.com;
ssl_certificate /etc/letsencrypt/live/figcraft.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/figcraft.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Under a path instead: `location /figcraft/ { ... }` with the same lines, and
# public_url = "wss://figcraft.example.com/figcraft/".
location / {
proxy_pass http://127.0.0.1:47900;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
# Apps ping every 15 seconds and the server drops a connection idle for 45: stay above both.
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
}

48
packaging/server/package.sh Executable file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Build and package the collaboration server for this machine (see docs/server.md):
#
# $DIST/figcraft-server-<version>-<os>-<arch>.tar.gz
#
# One directory with the figcraft-server binary, the licences, docs/server.md and the deployment
# examples (systemd unit, Caddyfile, nginx). A headless binary: no desktop files, no fonts.
#
# Usage: packaging/server/package.sh [--skip-build]
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/server"
SKIP_BUILD=0
case "${1:-}" in
--skip-build) SKIP_BUILD=1 ;;
"") ;;
-h | --help) sed -n '2,9p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
arm64) ARCH=aarch64 ;;
amd64) ARCH=x86_64 ;;
esac
NAME="figcraft-server-$VERSION-$OS-$ARCH"
echo "==> figcraft-server $VERSION for $OS $ARCH"
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p figcraft-server)
fi
WORK="$CARGO_TARGET_DIR/server-package"
STAGE="$WORK/$NAME"
rm -rf "$WORK"
mkdir -p "$STAGE"
cp "$CARGO_TARGET_DIR/release/figcraft-server" "$STAGE/"
strip "$STAGE/figcraft-server" 2>/dev/null || true
for f in LICENSE-MIT LICENSE-APACHE NOTICE docs/server.md; do cp "$ROOT/$f" "$STAGE/"; done
cp "$HERE/figcraft-server.service" "$HERE/Caddyfile" "$HERE/nginx.conf" "$STAGE/"
# Smoke test: the binary runs and reports the version being shipped.
"$STAGE/figcraft-server" --version | grep -F "$VERSION"
tar -C "$WORK" -czf "$DIST/$NAME.tar.gz" "$NAME"
echo "==> $DIST/$NAME.tar.gz"