M9: standalone collaboration server with share links and permissions (#112)
figcraft-server hosts many files over the same hub the app uses for sessions, persists each as a checksummed snapshot plus an append-only log (synced before replies, compacted, damaged files skipped and reported) so restarts lose nothing confirmed, and gives per-file share links with edit or view access (hashed 128-bit tokens, revocable). The app can publish a file to a server and join server-hosted files; view-only tabs refuse edits. Protocol version 2.
This commit is contained in:
parent
19343f5331
commit
07fa4d92ed
|
|
@ -1607,6 +1607,24 @@ dependencies = [
|
|||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"env_logger",
|
||||
"figcraft-collab",
|
||||
"figcraft-collab-net",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.3.1"
|
||||
|
|
|
|||
|
|
@ -139,7 +139,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
|||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **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 (undo is not per person yet). Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.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).
|
||||
- **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.
|
||||
|
||||
|
|
@ -149,6 +150,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
|||
git clone https://github.com/storytold/figcraft
|
||||
cd figcraft
|
||||
cargo run --release -p figcraft # the desktop app
|
||||
cargo run --release -p figcraft-server # the collaboration server (docs/server.md)
|
||||
cargo test --workspace # the test suite
|
||||
cargo xtask ci # fmt, clippy, tests, layering and asset checks
|
||||
```
|
||||
|
|
@ -166,7 +168,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
|
|||
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app on a local network, with per-person undo; no sharing or permissions yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** FigCraft is in **alpha**: about 47% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft can't open `.fig` files yet, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (live boolean editing and the vector tail: 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 (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: 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
|
||||
|
|
@ -45,7 +45,7 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human:
|
|||
|
||||
**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components
|
||||
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
|
||||
FigCraft can't open Figma's own `.fig` files, has no multiplayer, and nothing has been checked
|
||||
FigCraft can't open Figma's own `.fig` files, has only a preview of multiplayer, and nothing has been checked
|
||||
against real Figma documents. Ready-for-real-work is ~47%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is missing, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
|
||||
~75% **and** reliable `.fig` import: the `.fig` reader (30–60 h), a real-file corpus with measured
|
||||
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
|
||||
|
|
@ -59,7 +59,7 @@ the localization layer with Figma's own languages (20–35 h).
|
|||
| Features (depth) | 57% (core editor 67%) | 71–135 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 50% | 15–30 | [hardware-parity](docs/hardware-parity.md) |
|
||||
|
|
@ -123,7 +123,7 @@ 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): sharing and a standalone server with TLS on top of the live sessions (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, and the browser client landed): 45–90 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 landed): 45–90 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
|
|
@ -157,6 +157,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
|
||||
| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
[package]
|
||||
name = "figcraft-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "FigCraft's standalone collaboration server: keeps many files with share links (edit or view), persists them as snapshots plus a log, and serves live sessions (headless, native only)"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "figcraft-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
figcraft-collab = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
crc32fast = { workspace = true }
|
||||
toml = "1"
|
||||
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
|
||||
|
||||
[dev-dependencies]
|
||||
figcraft-engine = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
//! The command line: `figcraft-server` serves; `key`, `files` and `links` manage the data
|
||||
//! directory. When a server is running on the directory they ask it (so revoking a link
|
||||
//! disconnects whoever joined with it at once); otherwise they change the directory themselves.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_collab_net::{Endpoint, Invite, manage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::disk;
|
||||
use crate::store::{self, KEY, Store};
|
||||
|
||||
/// Where a running server says it listens.
|
||||
pub const SERVER_JSON: &str = "server.json";
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
figcraft-server: FigCraft's collaboration server (files, share links, live sessions)
|
||||
|
||||
Usage:
|
||||
figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE]
|
||||
figcraft-server key [--rotate] the address to publish to, with the server key
|
||||
figcraft-server files [list] the files kept
|
||||
figcraft-server files remove FILE remove a file and its links for good
|
||||
figcraft-server links list FILE a file's share links
|
||||
figcraft-server links create FILE --access edit|view [--label TEXT]
|
||||
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
|
||||
|
||||
Options (also keys of the --config TOML file):
|
||||
--listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network)
|
||||
--data DIR the data directory (default ./figcraft-data)
|
||||
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
|
||||
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
|
||||
max_connections, max_people, compact_after, compact_mb
|
||||
-h, --help this text
|
||||
-V, --version the version
|
||||
|
||||
Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens.
|
||||
";
|
||||
|
||||
/// What to do.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Command {
|
||||
Serve,
|
||||
Key { rotate: bool },
|
||||
Files,
|
||||
RemoveFile { file: String },
|
||||
Links { file: String },
|
||||
CreateLink { file: String, access: Access, label: String },
|
||||
Revoke { file: String, link: String },
|
||||
Help,
|
||||
Version,
|
||||
}
|
||||
|
||||
/// A parsed command line.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Cli {
|
||||
pub config: Config,
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
/// Read the arguments (after the program name).
|
||||
pub fn parse(args: &[String]) -> Result<Cli, String> {
|
||||
// The config file first: flags override it.
|
||||
let mut config = match args.iter().position(|a| a == "--config") {
|
||||
Some(i) => Config::load(Path::new(args.get(i + 1).ok_or("--config needs a file")?))?,
|
||||
None => Config::default(),
|
||||
};
|
||||
let mut words = Vec::new();
|
||||
let mut access = None;
|
||||
let mut label = String::new();
|
||||
let mut rotate = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value"));
|
||||
match a.as_str() {
|
||||
"-h" | "--help" => return Ok(Cli { config, command: Command::Help }),
|
||||
"-V" | "--version" => return Ok(Cli { config, command: Command::Version }),
|
||||
"--config" => {
|
||||
value("--config")?;
|
||||
}
|
||||
"--listen" => config.listen = value("--listen")?,
|
||||
"--data" => config.data = PathBuf::from(value("--data")?),
|
||||
"--public-url" => config.public_url = Some(value("--public-url")?),
|
||||
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
|
||||
"--label" => label = value("--label")?,
|
||||
"--rotate" => rotate = true,
|
||||
s if s.starts_with('-') => return Err(format!("unknown option {s}")),
|
||||
s => words.push(s.to_string()),
|
||||
}
|
||||
}
|
||||
let w: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||
let command = match w.as_slice() {
|
||||
[] | ["serve"] => Command::Serve,
|
||||
["key"] => Command::Key { rotate },
|
||||
["files"] | ["files", "list"] => Command::Files,
|
||||
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
|
||||
["links", "list", file] => Command::Links { file: file.to_string() },
|
||||
["links", "create", file] => {
|
||||
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
|
||||
}
|
||||
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
|
||||
_ => return Err(format!("unknown command `{}`", words.join(" "))),
|
||||
};
|
||||
config.check()?;
|
||||
Ok(Cli { config, command })
|
||||
}
|
||||
|
||||
/// What a running server writes into its data directory.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Running {
|
||||
pub listen: SocketAddr,
|
||||
pub pid: u32,
|
||||
}
|
||||
|
||||
/// Note where the server listens (for the command line).
|
||||
pub fn write_running(data: &Path, listen: SocketAddr) -> Result<(), String> {
|
||||
let text = serde_json::to_vec_pretty(&Running { listen, pid: std::process::id() }).map_err(|e| e.to_string())?;
|
||||
disk::write_atomic(&data.join(SERVER_JSON), &text).map_err(|e| format!("can't write {SERVER_JSON}: {e}"))
|
||||
}
|
||||
|
||||
/// `addr` as something this machine can connect to (the loopback address for "every interface").
|
||||
fn reachable(addr: SocketAddr) -> SocketAddr {
|
||||
match addr.ip() {
|
||||
IpAddr::V4(ip) if ip.is_unspecified() => SocketAddr::from(([127, 0, 0, 1], addr.port())),
|
||||
IpAddr::V6(ip) if ip.is_unspecified() => SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, addr.port())),
|
||||
_ => addr,
|
||||
}
|
||||
}
|
||||
|
||||
/// How share links and the publish address name the server: `public_url`, else the listen
|
||||
/// address (this machine's network address for "every interface").
|
||||
pub fn public_endpoint(cfg: &Config, listen: SocketAddr) -> Result<Endpoint, String> {
|
||||
if let Some(e) = cfg.public()? {
|
||||
return Ok(e);
|
||||
}
|
||||
let ip = if listen.ip().is_unspecified() { figcraft_collab_net::lan_ip().unwrap_or(reachable(listen).ip()) } else { listen.ip() };
|
||||
Ok(Endpoint::plain(SocketAddr::new(ip, listen.port()).to_string()))
|
||||
}
|
||||
|
||||
/// Run a command other than serving; what to print.
|
||||
pub fn run(cli: &Cli) -> Result<String, String> {
|
||||
let cfg = &cli.config;
|
||||
match &cli.command {
|
||||
Command::Help => Ok(USAGE.to_string()),
|
||||
Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))),
|
||||
Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()),
|
||||
Command::Key { rotate } => key(cfg, *rotate),
|
||||
Command::Files => match admin(cfg, Admin::Files)? {
|
||||
AdminReply::Files(files) => Ok(files_table(&files)),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? {
|
||||
AdminReply::Done => Ok(format!("Removed file {file} and its links.\n")),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::Links { file } => match admin(cfg, Admin::Links { file: file.clone() })? {
|
||||
AdminReply::Links(links) => Ok(links_table(&links)),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::CreateLink { file, access, label } => {
|
||||
match admin(cfg, Admin::CreateLink { file: file.clone(), access: *access, label: label.clone() })? {
|
||||
AdminReply::Link(l) => Ok(new_link(cfg, &l)?),
|
||||
other => unexpected(other),
|
||||
}
|
||||
}
|
||||
Command::Revoke { file, link } => match admin(cfg, Admin::Revoke { file: file.clone(), link: link.clone() })? {
|
||||
AdminReply::Done => Ok(format!("Revoked link {link}: it no longer joins, and whoever was in the file with it was disconnected.\n")),
|
||||
other => unexpected(other),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn unexpected(reply: AdminReply) -> Result<String, String> {
|
||||
match reply {
|
||||
AdminReply::Failed { reason } => Err(reason),
|
||||
other => Err(format!("the server answered {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The running server's address, when one holds the data directory.
|
||||
fn running(data: &Path) -> Result<SocketAddr, String> {
|
||||
let bytes = disk::read_capped(&data.join(SERVER_JSON), 1 << 16).map_err(|e| format!("a server is using {} but {e}", data.display()))?;
|
||||
let r: Running = serde_json::from_slice(&bytes).map_err(|e| format!("{SERVER_JSON} is damaged ({e})"))?;
|
||||
Ok(reachable(r.listen))
|
||||
}
|
||||
|
||||
/// Do one request: through the running server, or on the data directory.
|
||||
fn admin(cfg: &Config, req: Admin) -> Result<AdminReply, String> {
|
||||
match Store::try_open(&cfg.data, cfg.store())? {
|
||||
Some(mut store) => Ok(store.admin(req, &|_| None).reply),
|
||||
None => {
|
||||
let addr = running(&cfg.data)?;
|
||||
let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?;
|
||||
let replies = manage::request(&Endpoint::plain(addr.to_string()), key.trim(), vec![req], &cfg.net(), Duration::from_secs(5))?;
|
||||
replies.into_iter().next().ok_or_else(|| "the server didn't answer".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The address to publish to, with the key.
|
||||
fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
|
||||
let mut store = Store::try_open(&cfg.data, cfg.store())?;
|
||||
let listen = match &store {
|
||||
Some(_) => cfg.addr()?,
|
||||
None => running(&cfg.data)?,
|
||||
};
|
||||
let key = match (store.as_mut(), rotate) {
|
||||
(Some(s), true) => s.rotate_key()?,
|
||||
(Some(s), false) => s.key().to_string(),
|
||||
(None, true) => return Err("stop the server to replace its key".into()),
|
||||
(None, false) => std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?.trim().to_string(),
|
||||
};
|
||||
let public = public_endpoint(cfg, listen)?;
|
||||
let mut out = String::new();
|
||||
if rotate {
|
||||
out.push_str("A new server key: the old one no longer works.\n");
|
||||
}
|
||||
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
|
||||
let _ = writeln!(out, " {}?key={key}", public.url());
|
||||
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
|
||||
let listen = match running(&cfg.data) {
|
||||
Ok(a) if store::lock(&cfg.data)?.is_none() => a,
|
||||
_ => cfg.addr()?,
|
||||
};
|
||||
let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() };
|
||||
let mut out = String::new();
|
||||
let what = if l.link.access == Access::View { "view" } else { "edit" };
|
||||
let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file);
|
||||
let _ = writeln!(out, " {}", invite.link());
|
||||
let _ = writeln!(out, " {}", invite.url());
|
||||
let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token).");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn files_table(files: &[FileInfo]) -> String {
|
||||
if files.is_empty() {
|
||||
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
|
||||
}
|
||||
let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n");
|
||||
for f in files {
|
||||
let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn links_table(links: &[LinkInfo]) -> String {
|
||||
if links.is_empty() {
|
||||
return "No links (make one with `figcraft-server links create FILE --access view`).\n".into();
|
||||
}
|
||||
let mut out = String::from("LINK ACCESS CREATED LABEL\n");
|
||||
for l in links {
|
||||
let _ = writeln!(out, "{:<16} {:<6} {:<20} {}", l.id, l.access.id(), date(l.created), l.label);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn elide(s: &str, n: usize) -> String {
|
||||
if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::<String>()) }
|
||||
}
|
||||
|
||||
fn size(bytes: u64) -> String {
|
||||
match bytes {
|
||||
b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / f64::from(1 << 20)),
|
||||
b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
|
||||
b => format!("{b} B"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970.
|
||||
pub fn date(secs: u64) -> String {
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
//! The command line: parsing (flags over the config file), and managing a data directory both
|
||||
//! on its own and through the server running on it.
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::Access;
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use crate::cli::{self, Cli, Command, date, parse};
|
||||
use crate::config::Config;
|
||||
use crate::store::Store;
|
||||
use crate::test_util::{TempDir, config};
|
||||
|
||||
fn args(s: &[&str]) -> Vec<String> {
|
||||
s.iter().map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
fn run(dir: &std::path::Path, command: Command) -> Result<String, String> {
|
||||
cli::run(&Cli { config: config(dir), command })
|
||||
}
|
||||
|
||||
/// A data directory with one published file: its id.
|
||||
fn with_file(dir: &std::path::Path) -> String {
|
||||
let mut store = Store::open(dir, config(dir).store()).unwrap();
|
||||
let snapshot = Replica::new(Document::new("Board")).snapshot().unwrap();
|
||||
store.publish("Board", &snapshot).unwrap().0.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing() {
|
||||
let c = parse(&[]).unwrap();
|
||||
assert_eq!(c.command, Command::Serve);
|
||||
assert_eq!(c.config, Config::default());
|
||||
let c = parse(&args(&["serve", "--listen", "0.0.0.0:47900", "--data", "d", "--public-url", "wss://f.example.com/live/"])).unwrap();
|
||||
assert_eq!((c.config.listen.as_str(), c.config.data.to_str()), ("0.0.0.0:47900", Some("d")));
|
||||
assert_eq!(
|
||||
parse(&args(&["links", "create", "f1", "--access", "view", "--label", "Client"])).unwrap().command,
|
||||
Command::CreateLink { file: "f1".into(), access: Access::View, label: "Client".into() }
|
||||
);
|
||||
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files);
|
||||
assert_eq!(parse(&args(&["key", "--rotate"])).unwrap().command, Command::Key { rotate: true });
|
||||
assert_eq!(parse(&args(&["-h"])).unwrap().command, Command::Help);
|
||||
for bad in
|
||||
[&["--nope"][..], &["links", "create", "f1"], &["--listen", "nowhere"], &["--listen"], &["frobnicate"], &["--public-url", "ws://h/?key=k"]]
|
||||
{
|
||||
assert!(parse(&args(bad)).is_err(), "{bad:?}");
|
||||
}
|
||||
// A config file, with flags over it; unknown keys are errors.
|
||||
let dir = TempDir::new("cli-config");
|
||||
let file = dir.path().join("server.toml");
|
||||
std::fs::write(&file, "listen = \"0.0.0.0:47901\"\nmax_files = 7\ncompact_after = 50\n").unwrap();
|
||||
let c = parse(&args(&["--config", file.to_str().unwrap(), "--listen", "127.0.0.1:1"])).unwrap();
|
||||
assert_eq!((c.config.listen.as_str(), c.config.max_files, c.config.compact_after), ("127.0.0.1:1", 7, 50));
|
||||
std::fs::write(&file, "max_files = 0\n").unwrap();
|
||||
assert!(parse(&args(&["--config", file.to_str().unwrap()])).unwrap_err().contains("max_files"));
|
||||
std::fs::write(&file, "colour = \"blue\"\n").unwrap();
|
||||
assert!(parse(&args(&["--config", file.to_str().unwrap()])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managing_a_directory_on_its_own() {
|
||||
let dir = TempDir::new("cli-offline");
|
||||
let file = with_file(dir.path());
|
||||
let list = run(dir.path(), Command::Files).unwrap();
|
||||
assert!(list.contains(&file) && list.contains("Board"), "{list}");
|
||||
let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::View, label: "Client".into() }).unwrap();
|
||||
assert!(made.contains("New view link") && made.contains(&format!("file={file}&token=")), "{made}");
|
||||
let links = run(dir.path(), Command::Links { file: file.clone() }).unwrap();
|
||||
assert_eq!(links.lines().count(), 4, "a header and three links: {links}");
|
||||
let id = links.lines().find(|l| l.contains("Client")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
|
||||
run(dir.path(), Command::Revoke { file: file.clone(), link: id.clone() }).unwrap();
|
||||
assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap_err().contains("no such link"));
|
||||
// The key: printed as the address to publish to; replaced on request.
|
||||
let key = run(dir.path(), Command::Key { rotate: false }).unwrap();
|
||||
assert!(key.contains("ws://127.0.0.1:") && key.contains("?key="), "{key}");
|
||||
let rotated = run(dir.path(), Command::Key { rotate: true }).unwrap();
|
||||
assert_ne!(key.lines().nth(1), rotated.lines().nth(2));
|
||||
run(dir.path(), Command::RemoveFile { file: file.clone() }).unwrap();
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("No files yet"));
|
||||
assert!(run(dir.path(), Command::Links { file }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managing_through_the_running_server() {
|
||||
let dir = TempDir::new("cli-online");
|
||||
let file = with_file(dir.path());
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
// The directory is the server's now: commands ask it.
|
||||
let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::Edit, label: "Team".into() }).unwrap();
|
||||
assert!(made.contains(&format!("host={}", server.addr())), "links name the running server: {made}");
|
||||
let links = run(dir.path(), Command::Links { file: file.clone() }).unwrap();
|
||||
assert!(links.contains("Team"), "{links}");
|
||||
let id = links.lines().find(|l| l.contains("Team")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
|
||||
assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap().contains("Revoked"));
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
|
||||
assert!(run(dir.path(), Command::Key { rotate: true }).unwrap_err().contains("stop the server"));
|
||||
assert!(run(dir.path(), Command::Key { rotate: false }).unwrap().contains(&format!("ws://{}/?key=", server.addr())));
|
||||
server.stop();
|
||||
// Stopped: the directory is free again.
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates() {
|
||||
assert_eq!(date(0), "1970-01-01 00:00 UTC");
|
||||
assert_eq!(date(951_782_400), "2000-02-29 00:00 UTC");
|
||||
assert_eq!(date(1_791_986_580), "2026-10-14 14:03 UTC");
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use figcraft_collab_net::{Endpoint, NetLimits};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::store::StoreConfig;
|
||||
|
||||
/// The port the server listens on unless told otherwise (the app's sessions try it first too).
|
||||
pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT;
|
||||
|
||||
/// Everything the server can be told (the TOML file's keys are these names).
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// Where to listen: `127.0.0.1:47900` (this machine only) unless told otherwise.
|
||||
pub listen: String,
|
||||
/// The data directory (files, links, the server key).
|
||||
pub data: PathBuf,
|
||||
/// How share links name this server when it isn't reached at `listen` (a reverse proxy:
|
||||
/// `wss://figcraft.example.com/`).
|
||||
pub public_url: Option<String>,
|
||||
/// Most files kept.
|
||||
pub max_files: usize,
|
||||
/// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at
|
||||
/// most 64 MiB).
|
||||
pub max_file_mb: u64,
|
||||
/// Most share links per file.
|
||||
pub max_links: usize,
|
||||
/// Most open connections.
|
||||
pub max_connections: usize,
|
||||
/// Most people in one file at once.
|
||||
pub max_people: usize,
|
||||
/// Compact a file's log into a new snapshot after this many entries…
|
||||
pub compact_after: usize,
|
||||
/// …or once the log is this large (MiB).
|
||||
pub compact_mb: u64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
listen: format!("127.0.0.1:{DEFAULT_PORT}"),
|
||||
data: PathBuf::from("figcraft-data"),
|
||||
public_url: None,
|
||||
max_files: 1000,
|
||||
max_file_mb: 64,
|
||||
max_links: 100,
|
||||
max_connections: 512,
|
||||
max_people: 256,
|
||||
compact_after: 1000,
|
||||
compact_mb: 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read a TOML file's settings (unknown keys are errors).
|
||||
pub fn load(path: &Path) -> Result<Config, String> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("can't read {}: {e}", path.display()))?;
|
||||
toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The listen address.
|
||||
pub fn addr(&self) -> Result<SocketAddr, String> {
|
||||
self.listen.trim().parse().map_err(|_| format!("`listen` must be an address with a port, like 0.0.0.0:{DEFAULT_PORT}"))
|
||||
}
|
||||
|
||||
/// The public URL, when set: a `ws://` or `wss://` address without a query.
|
||||
pub fn public(&self) -> Result<Option<Endpoint>, String> {
|
||||
let Some(url) = self.public_url.as_deref().map(str::trim).filter(|u| !u.is_empty()) else { return Ok(None) };
|
||||
let (e, query) = Endpoint::parse(url).map_err(|e| format!("`public_url`: {e}"))?;
|
||||
if !query.is_empty() {
|
||||
return Err("`public_url` can't have a query (no keys or tokens in it)".into());
|
||||
}
|
||||
Ok(Some(e))
|
||||
}
|
||||
|
||||
/// Check everything (before starting).
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
self.addr()?;
|
||||
self.public()?;
|
||||
for (name, v) in [
|
||||
("max_files", self.max_files as u64),
|
||||
("max_file_mb", self.max_file_mb),
|
||||
("max_links", self.max_links as u64),
|
||||
("max_connections", self.max_connections as u64),
|
||||
("max_people", self.max_people as u64),
|
||||
("compact_after", self.compact_after as u64),
|
||||
("compact_mb", self.compact_mb),
|
||||
] {
|
||||
if v == 0 {
|
||||
return Err(format!("`{name}` must be at least 1"));
|
||||
}
|
||||
}
|
||||
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
|
||||
return Err("sizes are in MiB and at most 4096".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The store's part.
|
||||
pub fn store(&self) -> StoreConfig {
|
||||
let mib = |n: u64| n.saturating_mul(1 << 20);
|
||||
StoreConfig {
|
||||
max_files: self.max_files,
|
||||
max_file_bytes: mib(self.max_file_mb),
|
||||
max_links: self.max_links,
|
||||
compact_after: self.compact_after,
|
||||
compact_bytes: mib(self.compact_mb),
|
||||
core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() },
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport's part.
|
||||
pub fn net(&self) -> NetLimits {
|
||||
NetLimits { max_connections: self.max_connections, ..NetLimits::default() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
//! The server's files on disk and reading them back without trusting them: atomic writes (a
|
||||
//! temporary file beside the target, flushed, then renamed over it), the snapshot format and the
|
||||
//! append-only log format.
|
||||
//!
|
||||
//! - `snapshot.bin`: `FCSN`, a format byte, the generation (u64 LE), a CRC-32 of the rest (u32 LE),
|
||||
//! then the session state ([`SessionState`] in the wire encoding).
|
||||
//! - `log.bin`: `FCLG`, a format byte, the generation of the snapshot it continues, then records:
|
||||
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
|
||||
//!
|
||||
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
|
||||
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
|
||||
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use figcraft_collab::message;
|
||||
use figcraft_collab::{Journal, SessionState};
|
||||
|
||||
pub const SNAPSHOT: &str = "snapshot.bin";
|
||||
pub const LOG: &str = "log.bin";
|
||||
pub const META: &str = "meta.json";
|
||||
pub const LINKS: &str = "links.json";
|
||||
|
||||
const SNAP_MAGIC: &[u8; 4] = b"FCSN";
|
||||
const LOG_MAGIC: &[u8; 4] = b"FCLG";
|
||||
const FORMAT: u8 = 1;
|
||||
const SNAP_HEADER: usize = 4 + 1 + 8 + 4;
|
||||
pub const LOG_HEADER: usize = 4 + 1 + 8;
|
||||
const RECORD_HEADER: usize = 8;
|
||||
|
||||
/// `path` with `.tmp` added: where its next version is written first.
|
||||
pub fn temp_of(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
|
||||
name.push(".tmp");
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Replace `path` with `bytes` atomically: a reader sees the old or the new content, never a mix.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let tmp = temp_of(path);
|
||||
{
|
||||
let mut f = File::create(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, path)?;
|
||||
sync_dir(path.parent());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make a rename in `dir` durable (directories can't be opened for that on Windows).
|
||||
pub fn sync_dir(dir: Option<&Path>) {
|
||||
#[cfg(unix)]
|
||||
if let Some(d) = dir
|
||||
&& let Ok(f) = File::open(d)
|
||||
{
|
||||
let _ = f.sync_all();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = dir;
|
||||
}
|
||||
|
||||
/// Read a file of at most `max` bytes (a longer one is refused without being read).
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
|
||||
let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?;
|
||||
let len = f.metadata().map_err(|e| e.to_string())?.len();
|
||||
if len > max {
|
||||
return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(len).unwrap_or(0));
|
||||
f.take(max).read_to_end(&mut bytes).map_err(|e| format!("can't read {}: {e}", name(path)))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn name(path: &Path) -> String {
|
||||
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn crc(bytes: &[u8]) -> u32 {
|
||||
crc32fast::hash(bytes)
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_le_bytes)
|
||||
}
|
||||
|
||||
fn u64_at(b: &[u8], at: usize) -> Option<u64> {
|
||||
b.get(at..at + 8).and_then(|s| s.try_into().ok()).map(u64::from_le_bytes)
|
||||
}
|
||||
|
||||
/// A snapshot file's bytes.
|
||||
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
|
||||
let body = message::encode(state).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
|
||||
out.extend_from_slice(SNAP_MAGIC);
|
||||
out.push(FORMAT);
|
||||
out.extend_from_slice(&generation.to_le_bytes());
|
||||
out.extend_from_slice(&crc(&body).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A snapshot's generation and body, when its header and checksum are right.
|
||||
fn snapshot_body(bytes: &[u8]) -> Result<(u64, &[u8]), String> {
|
||||
if bytes.get(..4) != Some(SNAP_MAGIC.as_slice()) {
|
||||
return Err("the snapshot isn't one (wrong header)".into());
|
||||
}
|
||||
if bytes.get(4) != Some(&FORMAT) {
|
||||
return Err("the snapshot is in a format this version doesn't read".into());
|
||||
}
|
||||
let (Some(generation), Some(sum), Some(body)) = (u64_at(bytes, 5), u32_at(bytes, 13), bytes.get(SNAP_HEADER..)) else {
|
||||
return Err("the snapshot is cut short".into());
|
||||
};
|
||||
if crc(body) != sum {
|
||||
return Err("the snapshot is damaged (checksum mismatch)".into());
|
||||
}
|
||||
Ok((generation, body))
|
||||
}
|
||||
|
||||
/// A snapshot's generation, after checking its header and checksum (no decoding).
|
||||
pub fn check_snapshot(bytes: &[u8]) -> Result<u64, String> {
|
||||
snapshot_body(bytes).map(|(g, _)| g)
|
||||
}
|
||||
|
||||
/// A snapshot's generation and session state.
|
||||
pub fn decode_snapshot(bytes: &[u8]) -> Result<(u64, SessionState), String> {
|
||||
let (generation, body) = snapshot_body(bytes)?;
|
||||
let state = message::decode::<SessionState>(body, body.len()).map_err(|e| format!("the snapshot can't be read: {e}"))?;
|
||||
Ok((generation, state))
|
||||
}
|
||||
|
||||
/// An empty log continuing snapshot `generation`.
|
||||
pub fn log_header(generation: u64) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(LOG_HEADER);
|
||||
out.extend_from_slice(LOG_MAGIC);
|
||||
out.push(FORMAT);
|
||||
out.extend_from_slice(&generation.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// One log record.
|
||||
pub fn record(entry: &Journal) -> Result<Vec<u8>, String> {
|
||||
let body = message::encode(entry).map_err(|e| e.to_string())?;
|
||||
let len = u32::try_from(body.len()).map_err(|_| "a journal entry over 4 GiB".to_string())?;
|
||||
let mut out = Vec::with_capacity(RECORD_HEADER + body.len());
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&crc(&body).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a log holds.
|
||||
#[derive(Debug)]
|
||||
pub struct LogRead {
|
||||
pub generation: u64,
|
||||
pub entries: Vec<Journal>,
|
||||
/// Why it ends before the end of the file (a damaged or cut-short record), if it does.
|
||||
pub damaged: Option<String>,
|
||||
}
|
||||
|
||||
/// Read a log: its generation and the entries up to the first damaged record. Only a header that
|
||||
/// isn't a log's is an error.
|
||||
pub fn read_log(bytes: &[u8]) -> Result<LogRead, String> {
|
||||
if bytes.get(..4) != Some(LOG_MAGIC.as_slice()) {
|
||||
return Err("the log isn't one (wrong header)".into());
|
||||
}
|
||||
if bytes.get(4) != Some(&FORMAT) {
|
||||
return Err("the log is in a format this version doesn't read".into());
|
||||
}
|
||||
let generation = u64_at(bytes, 5).ok_or("the log is cut short")?;
|
||||
let mut at = LOG_HEADER;
|
||||
let mut entries = Vec::new();
|
||||
let mut damaged = None;
|
||||
while at < bytes.len() {
|
||||
let (Some(len), Some(sum)) = (u32_at(bytes, at), u32_at(bytes, at + 4)) else {
|
||||
damaged = Some(format!("a record cut short at byte {at}"));
|
||||
break;
|
||||
};
|
||||
let start = at + RECORD_HEADER;
|
||||
let Some(body) = usize::try_from(len).ok().and_then(|n| bytes.get(start..start.checked_add(n)?)) else {
|
||||
damaged = Some(format!("a record cut short at byte {at}"));
|
||||
break;
|
||||
};
|
||||
if crc(body) != sum {
|
||||
damaged = Some(format!("a damaged record at byte {at}"));
|
||||
break;
|
||||
}
|
||||
match message::decode::<Journal>(body, body.len()) {
|
||||
Ok(e) => entries.push(e),
|
||||
Err(e) => {
|
||||
damaged = Some(format!("an unreadable record at byte {at} ({e})"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
at = start + body.len();
|
||||
}
|
||||
Ok(LogRead { generation, entries, damaged })
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
//! `figcraft-server`: FigCraft's standalone collaboration server (milestone M9, step 5 of
|
||||
//! `docs/collab-protocol.md`; running it: `docs/server.md`).
|
||||
//!
|
||||
//! It keeps many files in a data directory ([`store`]), each with share links that let people
|
||||
//! edit or only view, and serves their live sessions through the same hub the desktop app hosts
|
||||
//! with ([`figcraft_collab_net::hub`]). Files persist as a snapshot plus an append-only log of what
|
||||
//! changed ([`disk`]), compacted into a new snapshot from time to time, so a restart loses nothing
|
||||
//! that was confirmed and clients resume where they were. There are no user accounts yet: share
|
||||
//! links are the access, and the server key manages files.
|
||||
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod disk;
|
||||
pub mod store;
|
||||
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
|
||||
use figcraft_collab_net::hub::Hosting;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::store::Store;
|
||||
|
||||
/// A running server. Dropping it stops it: open files are saved, everyone is told the server is
|
||||
/// restarting (their apps reconnect once it is back).
|
||||
pub struct Running {
|
||||
hosting: Hosting,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
/// Where it listens.
|
||||
pub fn addr(&self) -> SocketAddr {
|
||||
self.hosting.addr()
|
||||
}
|
||||
|
||||
/// Open connections.
|
||||
pub fn connections(&self) -> usize {
|
||||
self.hosting.connections()
|
||||
}
|
||||
|
||||
/// Stop (see the type's docs).
|
||||
pub fn stop(&mut self) {
|
||||
self.hosting.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start serving `cfg.data` on `cfg.listen`. Files that can't be read are skipped and logged.
|
||||
pub fn start(cfg: &Config) -> Result<Running, String> {
|
||||
cfg.check()?;
|
||||
let store = Store::open(&cfg.data, cfg.store())?;
|
||||
for p in store.problems() {
|
||||
log::warn!("{p}");
|
||||
}
|
||||
let files = store.len();
|
||||
let addr = cfg.addr()?;
|
||||
let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?;
|
||||
let addr = listener.local_addr().map_err(|e| e.to_string())?;
|
||||
cli::write_running(&cfg.data, addr)?;
|
||||
let public = cli::public_endpoint(cfg, addr)?;
|
||||
let hosting = Hosting::serve(listener, store, cfg.net()).map_err(|e| format!("can't start serving: {e}"))?;
|
||||
log::info!(
|
||||
"figcraft-server {}: {files} file{} in {}; listening on {addr}; links name {}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
if files == 1 { "" } else { "s" },
|
||||
cfg.data.display(),
|
||||
public.url()
|
||||
);
|
||||
if addr.ip().is_loopback() {
|
||||
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 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cli_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
#[cfg(test)]
|
||||
mod store_tests;
|
||||
#[cfg(test)]
|
||||
mod test_util;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
//! `figcraft-server`: see `docs/server.md`, or run it with `--help`.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use figcraft_server::cli::{self, Command};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let cli = match cli::parse(&args) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("figcraft-server: {e}\n(figcraft-server --help lists what it takes)");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
if cli.command != Command::Serve {
|
||||
return match cli::run(&cli) {
|
||||
Ok(out) => {
|
||||
print!("{out}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("figcraft-server: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
};
|
||||
}
|
||||
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();
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("{e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
//! The server end to end over loopback, in-process with a temporary data directory: an app
|
||||
//! publishes its file and syncs through the server, others join with the edit link and the view
|
||||
//! link, edits converge and the viewer's are refused (by its app, and by the server for a client
|
||||
//! that skips the check), the server restarts mid-session and everyone resumes without losing a
|
||||
//! confirmed edit, revoked links stop working at once, and fuzzed frames don't hurt it.
|
||||
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, ServerMsg};
|
||||
use figcraft_collab::sim::Rng;
|
||||
use figcraft_collab::{Client, VIEW_ONLY};
|
||||
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::live::{Published, Status};
|
||||
use serde_json::{Value, json};
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
use crate::test_util::{TempDir, config, pump, same, session};
|
||||
|
||||
fn key(dir: &Path) -> String {
|
||||
std::fs::read_to_string(dir.join(crate::store::KEY)).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn live(s: &Session) -> bool {
|
||||
s.live.status == Status::Live && s.live.syncing()
|
||||
}
|
||||
|
||||
/// `a` publishes its open file to the server at `addr`; the links.
|
||||
fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
|
||||
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
|
||||
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
a.live.published.clone().unwrap()
|
||||
}
|
||||
|
||||
fn join(link: &str, name: &str) -> Session {
|
||||
let mut s = session(name);
|
||||
s.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A bare client: hello, then what it's told.
|
||||
struct Raw(WebSocket<TcpStream>);
|
||||
|
||||
impl Raw {
|
||||
fn connect(addr: SocketAddr) -> Raw {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
Raw(tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0)
|
||||
}
|
||||
|
||||
fn send(&mut self, m: &ClientMsg) {
|
||||
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
|
||||
}
|
||||
|
||||
fn next(&mut self) -> ServerMsg {
|
||||
loop {
|
||||
if let Message::Binary(b) = self.0.read().unwrap() {
|
||||
return message::decode(&b, usize::MAX).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_then_edit_and_view_together() {
|
||||
let dir = TempDir::new("share");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let mut a = session("Ada");
|
||||
a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 80})).unwrap();
|
||||
let addr = server.addr();
|
||||
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir.path()))})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
// Nothing changes it until it is on the server.
|
||||
let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string();
|
||||
assert!(e.contains("being published"), "{e}");
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
let p = a.live.published.clone().unwrap();
|
||||
assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab");
|
||||
assert_eq!(a.live_summary()["readOnly"], false);
|
||||
assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:"));
|
||||
assert_ne!(p.edit, p.view);
|
||||
// Bo joins with the edit link, Cy with the view link.
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
let mut c = join(&p.view, "Cy");
|
||||
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2);
|
||||
assert_eq!(b.live_summary()["access"], "edit");
|
||||
assert_eq!(c.live_summary()["access"], "view");
|
||||
assert_eq!(c.live_summary()["readOnly"], true);
|
||||
assert!(same(a.doc().unwrap(), b.doc().unwrap()) && same(a.doc().unwrap(), c.doc().unwrap()));
|
||||
// Ada and Bo edit; everyone converges, Cy included.
|
||||
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap());
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Ada's rectangle at Bo", |s| s[1].doc().unwrap().contains(rect));
|
||||
b.execute("node.set", &json!({"ids": [rect.0], "props": {"name": "Card"}})).unwrap();
|
||||
pump(&mut [&mut a, &mut b, &mut c], "everyone agreeing", |s| {
|
||||
let (ad, bd, cd) = (s[0].doc().unwrap(), s[1].doc().unwrap(), s[2].doc().unwrap());
|
||||
same(ad, bd) && same(ad, cd) && cd.node(rect).is_some_and(|n| n.name == "Card")
|
||||
});
|
||||
// Cy can't change anything: commands and gestures are refused before anything is sent.
|
||||
let before = c.doc().unwrap().clone();
|
||||
let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string();
|
||||
assert!(e.contains("View only"), "{e}");
|
||||
assert!(c.execute("node.set", &json!({"ids": [rect.0], "props": {"name": "Mine"}})).is_err());
|
||||
assert!(c.begin_interaction("Move").is_err());
|
||||
assert!(same(&before, c.doc().unwrap()));
|
||||
// Cy is still seen: presence goes both ways.
|
||||
let cy = c.live.info.as_ref().unwrap().me;
|
||||
c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap();
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
|
||||
// A client that skips the app's check is refused by the server.
|
||||
let view = Invite::parse(&p.view).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&Client::hello(&view.file, &view.token, "Raw", ""));
|
||||
let welcome = loop {
|
||||
if let w @ ServerMsg::Welcome { .. } = raw.next() {
|
||||
break w;
|
||||
}
|
||||
};
|
||||
let mut client = Client::join(welcome).unwrap();
|
||||
assert_eq!(client.access(), Access::View);
|
||||
let mut d = client.document().clone();
|
||||
d.node_mut(rect).unwrap().name = "Sneaky".into();
|
||||
raw.send(&client.edit(&d).unwrap().unwrap());
|
||||
loop {
|
||||
if let ServerMsg::Reject { reason, .. } = raw.next() {
|
||||
assert_eq!(reason, VIEW_ONLY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card"));
|
||||
// Leaving keeps a local copy.
|
||||
c.execute("collab.leave", &json!({})).unwrap();
|
||||
assert_eq!(c.live.status, Status::Off);
|
||||
assert!(c.doc().unwrap().contains(rect));
|
||||
assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit");
|
||||
c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_mid_session_loses_no_confirmed_edit() {
|
||||
let dir = TempDir::new("restart");
|
||||
let mut cfg = config(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1);
|
||||
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
|
||||
pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect));
|
||||
let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me);
|
||||
// The server stops: both reconnect, and what Ada makes meanwhile waits.
|
||||
server.stop();
|
||||
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. })));
|
||||
let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap());
|
||||
cfg.listen = addr.to_string();
|
||||
let mut server = start_again(&cfg);
|
||||
pump(&mut [&mut a, &mut b], "everyone back with everything", |s| {
|
||||
s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect)
|
||||
});
|
||||
assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself");
|
||||
assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo");
|
||||
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
|
||||
// Someone new gets it all from the restarted server.
|
||||
let mut c = join(&p.view, "Cy");
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2]));
|
||||
assert!(same(a.doc().unwrap(), c.doc().unwrap()));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// Start a server on `cfg.listen` again (the port may take a moment to come free).
|
||||
fn start_again(cfg: &crate::config::Config) -> crate::Running {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
match crate::start(cfg) {
|
||||
Ok(s) => return s,
|
||||
Err(e) if start.elapsed() < Duration::from_secs(10) && e.contains("can't listen") => std::thread::sleep(Duration::from_millis(50)),
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_links_stop_working_at_once() {
|
||||
let dir = TempDir::new("revoke");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
let file = Invite::parse(&p.edit).unwrap().file;
|
||||
let endpoint = Endpoint::plain(addr.to_string());
|
||||
let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0);
|
||||
let AdminReply::Link(review) = ask(Admin::CreateLink { file: file.clone(), access: Access::View, label: "Review".into() }) else { panic!() };
|
||||
let link = Invite { server: endpoint.clone(), file: file.clone(), token: review.token.clone() }.link();
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
let mut c = join(&link, "Cy");
|
||||
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)));
|
||||
let AdminReply::Files(files) = ask(Admin::Files) else { panic!() };
|
||||
assert_eq!((files.len(), files[0].people), (1, 3));
|
||||
// Revoked: Cy is out at once, the others stay.
|
||||
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
|
||||
matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1
|
||||
});
|
||||
assert!(live(&b));
|
||||
// The link no longer joins.
|
||||
c.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid")));
|
||||
// Managing needs the key.
|
||||
let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("wrong server key"), "{e}");
|
||||
// Removing the file ends it for everyone in it.
|
||||
assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done);
|
||||
pump(&mut [&mut a, &mut b], "everyone out", |s| {
|
||||
s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed")))
|
||||
});
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzed_frames_dont_hurt_the_server() {
|
||||
let dir = TempDir::new("fuzz");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
let edit = Invite::parse(&p.edit).unwrap();
|
||||
let valid: Vec<Vec<u8>> = [
|
||||
Client::hello(&edit.file, &edit.token, "Fuzz", ""),
|
||||
ClientMsg::Manage(message::Manage { protocol: message::PROTOCOL, key: key(dir.path()), client_version: String::new() }),
|
||||
ClientMsg::Admin(Admin::Links { file: "0000000000000000".into() }),
|
||||
ClientMsg::Ping,
|
||||
]
|
||||
.iter()
|
||||
.map(|m| message::encode(m).unwrap())
|
||||
.collect();
|
||||
let mut rng = Rng::new(11);
|
||||
for round in 0..80 {
|
||||
let Ok(s) = TcpStream::connect(addr) else { continue };
|
||||
let _ = s.set_read_timeout(Some(Duration::from_millis(200)));
|
||||
let Ok((mut ws, _)) = tungstenite::client::client(format!("ws://{addr}/"), s) else { continue };
|
||||
for _ in 0..1 + rng.below(3) {
|
||||
let mut bytes = rng.pick(&valid).cloned().unwrap_or_default();
|
||||
if round % 5 == 0 {
|
||||
bytes = vec![0x5A; 100_000];
|
||||
}
|
||||
for _ in 0..1 + rng.below(4) {
|
||||
if bytes.is_empty() {
|
||||
break;
|
||||
}
|
||||
let i = rng.below(bytes.len());
|
||||
match rng.below(3) {
|
||||
0 => bytes[i] = rng.next_u64() as u8,
|
||||
1 => bytes.insert(i, rng.next_u64() as u8),
|
||||
_ => bytes.truncate(i.max(1)),
|
||||
}
|
||||
}
|
||||
let frame =
|
||||
if round % 7 == 0 { Message::Text(String::from_utf8_lossy(&bytes).into_owned().into()) } else { Message::Binary(Bytes::from(bytes)) };
|
||||
if ws.send(frame).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = ws.read();
|
||||
}
|
||||
// Still serving, the file intact.
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in after the noise", |s| live(s[1]) && live(s[0]));
|
||||
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
|
||||
server.stop();
|
||||
}
|
||||
|
|
@ -0,0 +1,615 @@
|
|||
//! The data directory: the files a server keeps, their share links and the server key, and the
|
||||
//! [`Backend`] the hub serves them through.
|
||||
//!
|
||||
//! ```text
|
||||
//! <data>/LOCK held while a server (or a command changing the directory) uses it
|
||||
//! <data>/server.json where the running server listens (for the command line)
|
||||
//! <data>/server.key the server key: managing files (publish, links, removal)
|
||||
//! <data>/files/<id>/meta.json name, created, updated, sequence number
|
||||
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token
|
||||
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
|
||||
//! <data>/files/<id>/log.bin what changed since (see `disk`)
|
||||
//! ```
|
||||
//!
|
||||
//! An open file appends its server's journal to its log; the hub has the store make the appends
|
||||
//! durable before anyone hears of them. After [`StoreConfig::compact_after`] entries or
|
||||
//! [`StoreConfig::compact_bytes`] of log, when everyone has left, and when the server stops, the
|
||||
//! state is written as the next snapshot and the log starts over. Link tokens are stored only as
|
||||
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
|
||||
//! never deleted.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions, TryLockError};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
|
||||
use figcraft_collab::{Change, Replica, Server, same_secret};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile};
|
||||
use figcraft_collab_net::token;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::disk::{self, LINKS, LOG, LOG_HEADER, META, SNAPSHOT};
|
||||
|
||||
pub const LOCK: &str = "LOCK";
|
||||
pub const KEY: &str = "server.key";
|
||||
pub const FILES: &str = "files";
|
||||
|
||||
/// What the store enforces.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct StoreConfig {
|
||||
pub max_files: usize,
|
||||
pub max_file_bytes: u64,
|
||||
pub max_links: usize,
|
||||
pub compact_after: usize,
|
||||
pub compact_bytes: u64,
|
||||
pub core: figcraft_collab::Limits,
|
||||
}
|
||||
|
||||
/// A file's description (`meta.json`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct Meta {
|
||||
name: String,
|
||||
created: u64,
|
||||
updated: u64,
|
||||
#[serde(default)]
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
/// A share link (`links.json`): the token itself is never stored.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct Link {
|
||||
id: String,
|
||||
access: Access,
|
||||
label: String,
|
||||
created: u64,
|
||||
/// SHA-256 of the token, hex.
|
||||
hash: String,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
fn info(&self, file: &str) -> LinkInfo {
|
||||
LinkInfo { id: self.id.clone(), file: file.to_string(), access: self.access, label: self.label.clone(), created: self.created }
|
||||
}
|
||||
}
|
||||
|
||||
/// An open file's log.
|
||||
struct Log {
|
||||
generation: u64,
|
||||
file: File,
|
||||
/// Records not written yet (written and synced at the next flush).
|
||||
pending: Vec<u8>,
|
||||
/// Entries and bytes since the last compaction.
|
||||
entries: usize,
|
||||
bytes: u64,
|
||||
/// Changed since the last compaction.
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
meta: Meta,
|
||||
links: Vec<Link>,
|
||||
/// Open: its log.
|
||||
log: Option<Log>,
|
||||
}
|
||||
|
||||
/// The data directory in use (it holds the directory's lock until dropped).
|
||||
pub struct Store {
|
||||
root: PathBuf,
|
||||
cfg: StoreConfig,
|
||||
key: String,
|
||||
files: BTreeMap<String, Entry>,
|
||||
/// What was skipped at startup or found damaged since, for the operator.
|
||||
problems: Vec<String>,
|
||||
_lock: File,
|
||||
}
|
||||
|
||||
/// Seconds since 1970.
|
||||
pub fn now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// SHA-256 of a token, hex.
|
||||
pub fn hash(token: &str) -> String {
|
||||
Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// A file or link id as this store makes them (and nothing a path could misuse).
|
||||
fn valid_id(s: &str) -> bool {
|
||||
(1..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
|
||||
/// A name others may see: trimmed, no control characters, at most 256 characters.
|
||||
fn clean_name(s: &str) -> String {
|
||||
s.trim().chars().filter(|c| !c.is_control()).take(256).collect()
|
||||
}
|
||||
|
||||
/// Take the data directory's lock: `None` when someone else holds it.
|
||||
pub fn lock(root: &Path) -> Result<Option<File>, String> {
|
||||
fs::create_dir_all(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?;
|
||||
let f = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(root.join(LOCK))
|
||||
.map_err(|e| format!("can't open {}: {e}", root.join(LOCK).display()))?;
|
||||
match f.try_lock() {
|
||||
Ok(()) => Ok(Some(f)),
|
||||
Err(TryLockError::WouldBlock) => Ok(None),
|
||||
Err(TryLockError::Error(e)) => Err(format!("can't lock the data directory: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the server key, or make one.
|
||||
fn load_key(root: &Path) -> Result<String, String> {
|
||||
let path = root.join(KEY);
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(k) if valid_id(k.trim()) && k.trim().len() >= 16 => Ok(k.trim().to_string()),
|
||||
Ok(_) => Err(format!("{} isn't a server key (delete it to make a new one)", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => write_key(root),
|
||||
Err(e) => Err(format!("can't read {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a new server key (only the operator's account can read it on Unix).
|
||||
fn write_key(root: &Path) -> Result<String, String> {
|
||||
let key = token::secret()?;
|
||||
let path = root.join(KEY);
|
||||
disk::write_atomic(&path, format!("{key}\n").as_bytes()).map_err(|e| format!("can't write {}: {e}", path.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Use the data directory `root` (made if missing). Fails when another server or command holds
|
||||
/// it. Files that can't be read are skipped and listed in [`problems`](Self::problems).
|
||||
pub fn open(root: &Path, cfg: StoreConfig) -> Result<Store, String> {
|
||||
Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))
|
||||
}
|
||||
|
||||
/// [`open`](Self::open), or `None` when someone else holds the directory.
|
||||
pub fn try_open(root: &Path, cfg: StoreConfig) -> Result<Option<Store>, String> {
|
||||
let Some(lock) = lock(root)? else { return Ok(None) };
|
||||
let key = load_key(root)?;
|
||||
let dir = root.join(FILES);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), problems: Vec::new(), _lock: lock };
|
||||
store.scan(&dir);
|
||||
Ok(Some(store))
|
||||
}
|
||||
|
||||
fn scan(&mut self, dir: &Path) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
|
||||
};
|
||||
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
|
||||
ids.sort();
|
||||
for (id, path) in ids {
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if !valid_id(&id) {
|
||||
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
|
||||
continue;
|
||||
}
|
||||
if self.files.len() >= self.cfg.max_files {
|
||||
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
|
||||
continue;
|
||||
}
|
||||
match self.check(&path) {
|
||||
Ok(entry) => {
|
||||
self.files.insert(id, entry);
|
||||
}
|
||||
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file folder's description and links, after checking its snapshot and log. Leftover
|
||||
/// temporary files (writes cut short) are removed.
|
||||
fn check(&self, path: &Path) -> Result<Entry, String> {
|
||||
for name in [SNAPSHOT, LOG, META, LINKS] {
|
||||
let tmp = disk::temp_of(&path.join(name));
|
||||
if tmp.exists() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
|
||||
let links: Vec<Link> =
|
||||
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
// A damaged log only loses its end; it is reported when the file opens.
|
||||
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
|
||||
&& let Ok(log) = disk::read_log(&bytes)
|
||||
&& log.generation != generation
|
||||
{
|
||||
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
|
||||
}
|
||||
Ok(Entry { meta, links, log: None })
|
||||
}
|
||||
|
||||
/// The server key (for managing files).
|
||||
pub fn key(&self) -> &str {
|
||||
&self.key
|
||||
}
|
||||
|
||||
/// Replace the server key; the new one.
|
||||
pub fn rotate_key(&mut self) -> Result<String, String> {
|
||||
self.key = write_key(&self.root)?;
|
||||
Ok(self.key.clone())
|
||||
}
|
||||
|
||||
/// What was skipped or found damaged.
|
||||
pub fn problems(&self) -> &[String] {
|
||||
&self.problems
|
||||
}
|
||||
|
||||
/// How many files are kept.
|
||||
pub fn len(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// No files are kept.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
|
||||
fn dir(&self, id: &str) -> PathBuf {
|
||||
self.root.join(FILES).join(id)
|
||||
}
|
||||
|
||||
fn entry(&mut self, id: &str) -> Result<&mut Entry, String> {
|
||||
self.files.get_mut(id).ok_or_else(|| "this file isn't on the server (it may have been removed)".to_string())
|
||||
}
|
||||
|
||||
fn info(&self, id: &str, e: &Entry, open: Option<OpenFile>) -> FileInfo {
|
||||
let dir = self.dir(id);
|
||||
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(dir.join(n)).ok()).map(|m| m.len()).sum();
|
||||
FileInfo {
|
||||
id: id.to_string(),
|
||||
name: e.meta.name.clone(),
|
||||
created: e.meta.created,
|
||||
updated: e.meta.updated,
|
||||
seq: open.map_or(e.meta.seq, |o| o.seq),
|
||||
bytes,
|
||||
people: open.map_or(0, |o| o.people),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
|
||||
pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
|
||||
if self.files.len() >= self.cfg.max_files {
|
||||
return Err(format!("the server keeps at most {} files", self.cfg.max_files));
|
||||
}
|
||||
let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
|
||||
if replica.document().node_count() > self.cfg.core.max_nodes {
|
||||
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
|
||||
}
|
||||
let mut doc = replica.document().clone();
|
||||
let name = match clean_name(name) {
|
||||
n if n.is_empty() => doc.name.clone(),
|
||||
n => n,
|
||||
};
|
||||
doc.name = name.clone();
|
||||
let server = Server::new(doc, "", self.cfg.core);
|
||||
let state = server.state().map_err(|e| e.to_string())?;
|
||||
let bytes = disk::encode_snapshot(1, &state)?;
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
|
||||
}
|
||||
let id = loop {
|
||||
let id = token::file_id()?;
|
||||
if !self.files.contains_key(&id) && !self.dir(&id).exists() {
|
||||
break id;
|
||||
}
|
||||
};
|
||||
let t = now();
|
||||
let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
|
||||
let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
|
||||
let meta = Meta { name, created: t, updated: t, seq: 0 };
|
||||
let dir = self.dir(&id);
|
||||
let written = fs::create_dir_all(&dir)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()))
|
||||
.and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
|
||||
.and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
|
||||
// Last: a folder without its description is an upload cut short.
|
||||
.and_then(|()| write_json(&dir.join(META), &meta));
|
||||
if let Err(e) = written {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
return Err(format!("couldn't keep the file: {e}"));
|
||||
}
|
||||
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None };
|
||||
let info = self.info(&id, &entry, None);
|
||||
self.files.insert(id.clone(), entry);
|
||||
log::info!("published file {id} ({} bytes)", bytes.len());
|
||||
Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }))
|
||||
}
|
||||
|
||||
/// The files kept.
|
||||
pub fn list(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileInfo> {
|
||||
self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
|
||||
}
|
||||
|
||||
/// A file's links.
|
||||
pub fn links(&mut self, file: &str) -> Result<Vec<LinkInfo>, String> {
|
||||
Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
|
||||
}
|
||||
|
||||
/// A new link to a file.
|
||||
pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result<NewLink, String> {
|
||||
let max = self.cfg.max_links;
|
||||
let path = self.dir(file).join(LINKS);
|
||||
let entry = self.entry(file)?;
|
||||
if entry.links.len() >= max {
|
||||
return Err(format!("a file has at most {max} links: revoke one first"));
|
||||
}
|
||||
let (link, token) = new_link(access, &clean_name(label), now())?;
|
||||
entry.links.push(link.clone());
|
||||
if let Err(e) = write_json(&path, &entry.links) {
|
||||
entry.links.pop();
|
||||
return Err(format!("couldn't keep the link: {e}"));
|
||||
}
|
||||
Ok(NewLink { link: link.info(file), token })
|
||||
}
|
||||
|
||||
/// Revoke a link.
|
||||
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
|
||||
let path = self.dir(file).join(LINKS);
|
||||
let entry = self.entry(file)?;
|
||||
let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
|
||||
let gone = entry.links.remove(at);
|
||||
if let Err(e) = write_json(&path, &entry.links) {
|
||||
entry.links.insert(at, gone);
|
||||
return Err(format!("couldn't revoke the link: {e}"));
|
||||
}
|
||||
log::info!("revoked link {link} of file {file}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
|
||||
/// longer served either way).
|
||||
pub fn remove(&mut self, file: &str) -> Result<(), String> {
|
||||
// Closed first: an open log would keep the folder from being deleted on Windows.
|
||||
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
|
||||
drop(entry);
|
||||
let dir = self.dir(file);
|
||||
fs::remove_dir_all(&dir)
|
||||
.map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
|
||||
log::info!("removed file {file}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
|
||||
fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
let mut issues = Vec::new();
|
||||
let mut fresh_log = true;
|
||||
let mut kept = 0;
|
||||
match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) {
|
||||
Ok(log) if log.generation == generation => {
|
||||
fresh_log = false;
|
||||
for entry in log.entries {
|
||||
if let Err(e) = server.replay(entry) {
|
||||
issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped"));
|
||||
fresh_log = true;
|
||||
break;
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
if let Some(why) = log.damaged {
|
||||
issues.push(format!("file {file}: its log ended with {why}; that part was dropped"));
|
||||
fresh_log = true;
|
||||
}
|
||||
}
|
||||
// From before a compaction that was cut short: the snapshot has it all.
|
||||
Ok(_) => {}
|
||||
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
|
||||
}
|
||||
for i in issues {
|
||||
log::warn!("{i}");
|
||||
self.problems.push(i);
|
||||
}
|
||||
server.journaling(true);
|
||||
let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
|
||||
let log = match (fresh_log, file_handle) {
|
||||
(false, Ok(f)) => {
|
||||
let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len());
|
||||
// Replayed entries make it worth compacting once everyone has left.
|
||||
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
|
||||
}
|
||||
// A new snapshot and log of the next generation, so appends start clean.
|
||||
_ => self.rewrite(file, generation + 1, &server)?,
|
||||
};
|
||||
self.entry(file)?.log = Some(log);
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
|
||||
let dir = self.dir(file);
|
||||
let state = server.state().map_err(|e| e.to_string())?;
|
||||
let bytes = disk::encode_snapshot(generation, &state)?;
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
|
||||
}
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?;
|
||||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.meta.seq = server.seq();
|
||||
let meta = e.meta.clone();
|
||||
if let Err(err) = write_json(&dir.join(META), &meta) {
|
||||
log::warn!("file {file}: couldn't update {META}: {err}");
|
||||
}
|
||||
}
|
||||
Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
|
||||
}
|
||||
|
||||
/// Compact an open file: its state as the next snapshot, an empty log.
|
||||
fn compact(&mut self, file: &str, server: &Server) {
|
||||
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
|
||||
match self.rewrite(file, generation + 1, server) {
|
||||
Ok(log) => {
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.log = Some(log);
|
||||
}
|
||||
log::debug!("compacted file {file}");
|
||||
}
|
||||
// The old snapshot and log still hold everything: carry on appending.
|
||||
Err(e) => log::error!("file {file}: compaction failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write and sync what's pending in every open log.
|
||||
fn flush_all(&mut self) {
|
||||
for (id, e) in &mut self.files {
|
||||
if let Some(log) = e.log.as_mut() {
|
||||
flush_log(id, log);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write and sync a log's pending records (kept for the next try when that fails).
|
||||
fn flush_log(id: &str, log: &mut Log) {
|
||||
if log.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
|
||||
Ok(()) => {
|
||||
log.bytes += log.pending.len() as u64;
|
||||
log.pending.clear();
|
||||
}
|
||||
Err(e) => log::error!("file {id}: couldn't write its log: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
|
||||
disk::write_atomic(path, &text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A new link and its token.
|
||||
fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> {
|
||||
let token = token::secret()?;
|
||||
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token))
|
||||
}
|
||||
|
||||
impl Backend for Store {
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
let entry = self.entry(file)?;
|
||||
let presented = hash(token);
|
||||
// Every link is compared (in constant time), so timing doesn't tell which one matched.
|
||||
let mut found = None;
|
||||
for l in &entry.links {
|
||||
if same_secret(&presented, &l.hash) {
|
||||
found = Some(Admission { access: l.access, link: Some(l.id.clone()) });
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
|
||||
}
|
||||
|
||||
fn open(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.load(file).map_err(|e| {
|
||||
let why = format!("file {file} can't be opened: {e}");
|
||||
log::error!("{why}");
|
||||
self.problems.push(why);
|
||||
"this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn changed(&mut self, file: &str, server: &mut Server) {
|
||||
let journal = server.take_journal();
|
||||
if journal.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes);
|
||||
let Some(e) = self.files.get_mut(file) else { return };
|
||||
let Some(log) = e.log.as_mut() else { return };
|
||||
for j in &journal {
|
||||
match disk::record(j) {
|
||||
Ok(r) => log.pending.extend(r),
|
||||
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
|
||||
}
|
||||
}
|
||||
log.entries += journal.len();
|
||||
log.dirty = true;
|
||||
e.meta.updated = now();
|
||||
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
|
||||
// The snapshot holds what's pending too.
|
||||
self.compact(file, server);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
self.flush_all();
|
||||
}
|
||||
|
||||
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
|
||||
let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
|
||||
flush_log(file, log);
|
||||
log.dirty
|
||||
});
|
||||
if dirty {
|
||||
self.compact(file, server);
|
||||
}
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
|
||||
self.flush_all();
|
||||
for (file, server) in open {
|
||||
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
|
||||
self.compact(file, server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manage(&mut self, key: &str) -> Result<(), String> {
|
||||
if same_secret(key, &self.key) { Ok(()) } else { Err("wrong server key".into()) }
|
||||
}
|
||||
|
||||
fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
|
||||
match req {
|
||||
Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
|
||||
Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
|
||||
Err(e) => failed(e),
|
||||
},
|
||||
Admin::Files => AdminReply::Files(self.list(open)).into(),
|
||||
Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
|
||||
Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
|
||||
Admin::Revoke { file, link } => match self.revoke(&file, &link) {
|
||||
Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None },
|
||||
Err(e) => failed(e),
|
||||
},
|
||||
Admin::Remove { file } => {
|
||||
if !self.files.contains_key(&file) {
|
||||
return failed("this file isn't on the server (it may have been removed)".into());
|
||||
}
|
||||
let reply = match self.remove(&file) {
|
||||
Ok(()) => AdminReply::Done,
|
||||
Err(reason) => AdminReply::Failed { reason },
|
||||
};
|
||||
Handled { reply, revoked: None, removed: Some(file) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Restarting
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
//! The data directory on its own: publishing, links and their hashes, persistence across a crash
|
||||
//! (snapshot plus log) and compaction, damaged files skipped and reported, caps, the lock, and
|
||||
//! temporary directories cleaned up.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg};
|
||||
use figcraft_collab::sim::Rng;
|
||||
use figcraft_collab::{Client, Outgoing, Replica, Server};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
|
||||
use crate::disk::{self, LINKS, LOG, META, SNAPSHOT};
|
||||
use crate::store::{FILES, Store, StoreConfig, hash};
|
||||
use crate::test_util::{TempDir, config};
|
||||
|
||||
fn cfg(dir: &std::path::Path) -> StoreConfig {
|
||||
config(dir).store()
|
||||
}
|
||||
|
||||
fn doc(name: &str) -> Document {
|
||||
let mut d = Document::new(name);
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
|
||||
d.repair();
|
||||
d
|
||||
}
|
||||
|
||||
fn snapshot(d: Document) -> Vec<figcraft_collab::Change> {
|
||||
Replica::new(d).snapshot().unwrap()
|
||||
}
|
||||
|
||||
/// Publish `name`; its id and the edit and view tokens.
|
||||
fn publish(store: &mut Store, name: &str) -> (String, String, String) {
|
||||
let (info, edit, view) = store.publish(name, &snapshot(doc(name))).unwrap();
|
||||
assert_eq!(edit.link.access, Access::Edit);
|
||||
assert_eq!(view.link.access, Access::View);
|
||||
(info.id, edit.token, view.token)
|
||||
}
|
||||
|
||||
/// A client in `server` (as the hub would let it in) and what it was told.
|
||||
fn join(server: &mut Server, access: Access) -> Client {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
|
||||
let (_, out) = server.join_as(h, access).unwrap();
|
||||
let Some(Outgoing::To(_, w)) = out.into_iter().next() else { panic!() };
|
||||
Client::join(w).unwrap()
|
||||
}
|
||||
|
||||
/// Rename the first page through `c`; the server takes the batch.
|
||||
fn rename(server: &mut Server, c: &mut Client, name: &str) {
|
||||
let mut d = c.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().name = name.into();
|
||||
let m = c.edit(&d).unwrap().unwrap();
|
||||
for o in server.receive(c.id(), m) {
|
||||
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
|
||||
c.receive(m, &figcraft_collab::NoRecompute).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_links_and_access() {
|
||||
let dir = TempDir::new("links");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
assert!(store.is_empty());
|
||||
let (id, edit, view) = publish(&mut store, "Café ☕");
|
||||
let files = store.list(&|_| None);
|
||||
assert_eq!(files.len(), 1);
|
||||
assert_eq!(files[0].name, "Café ☕");
|
||||
assert!(files[0].bytes > 0);
|
||||
// Each link lets in with its access; anything else doesn't.
|
||||
assert_eq!(store.admit(&id, &edit).unwrap().access, Access::Edit);
|
||||
assert_eq!(store.admit(&id, &view).unwrap().access, Access::View);
|
||||
assert!(store.admit(&id, "WrongWrongWrongWrong00").unwrap_err().contains("isn't valid"));
|
||||
assert!(store.admit("0000000000000000", &edit).unwrap_err().contains("isn't on the server"));
|
||||
// Tokens are kept only as hashes.
|
||||
let on_disk = fs::read_to_string(dir.path().join(FILES).join(&id).join(LINKS)).unwrap();
|
||||
assert!(!on_disk.contains(&edit) && !on_disk.contains(&view));
|
||||
assert!(on_disk.contains(&hash(&edit)) && on_disk.contains(&hash(&view)));
|
||||
// More links; revoking one stops it, the others keep working.
|
||||
let review = store.create_link(&id, Access::View, " Review\n").unwrap();
|
||||
assert_eq!(review.link.label, "Review");
|
||||
assert_eq!(store.links(&id).unwrap().len(), 3);
|
||||
assert_eq!(store.admit(&id, &review.token).unwrap().link.as_deref(), Some(review.link.id.as_str()));
|
||||
store.revoke(&id, &review.link.id).unwrap();
|
||||
assert!(store.admit(&id, &review.token).is_err());
|
||||
assert!(store.admit(&id, &view).is_ok());
|
||||
assert!(store.revoke(&id, &review.link.id).is_err(), "already gone");
|
||||
// Removed for good.
|
||||
store.remove(&id).unwrap();
|
||||
assert!(store.is_empty());
|
||||
assert!(!dir.path().join(FILES).join(&id).exists());
|
||||
assert!(store.admit(&id, &edit).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_survives_a_crash_and_compacts() {
|
||||
let dir = TempDir::new("crash");
|
||||
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
|
||||
let (id, before, seq) = {
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Crash");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
store.changed(&id, &mut server);
|
||||
for i in 0..5 {
|
||||
rename(&mut server, &mut a, &format!("Page {i}"));
|
||||
store.changed(&id, &mut server);
|
||||
}
|
||||
store.flush();
|
||||
// A crash: no compaction, nothing else written.
|
||||
(id, server.replica().clone(), server.seq())
|
||||
};
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
assert!(store.problems().is_empty(), "{:?}", store.problems());
|
||||
let mut server = store.open(&id).unwrap();
|
||||
assert_eq!(server.replica(), &before, "the log brought it back");
|
||||
assert_eq!(server.seq(), seq);
|
||||
// Compaction: everyone left; the snapshot has it all and the log starts over.
|
||||
assert!(store.idle(&id, &mut server));
|
||||
let snap = disk::decode_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
|
||||
assert_eq!(snap.1.seq, seq);
|
||||
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
|
||||
assert_eq!(log.generation, snap.0);
|
||||
assert!(log.entries.is_empty());
|
||||
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_log_is_compacted_while_open() {
|
||||
let dir = TempDir::new("compact");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
store.changed(&id, &mut server);
|
||||
let generation = || disk::check_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
|
||||
assert_eq!(generation(), 1);
|
||||
for i in 0..3 {
|
||||
rename(&mut server, &mut a, &format!("P{i}"));
|
||||
store.changed(&id, &mut server);
|
||||
store.flush();
|
||||
}
|
||||
assert_eq!(generation(), 2, "compacted after three entries");
|
||||
rename(&mut server, &mut a, "After");
|
||||
store.changed(&id, &mut server);
|
||||
store.flush();
|
||||
drop(store);
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let restored = store.open(&id).unwrap();
|
||||
assert_eq!(restored.replica(), server.replica());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_are_skipped_and_reported() {
|
||||
let dir = TempDir::new("damaged");
|
||||
let (bad_snapshot, bad_meta, cut_log, good) = {
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
let ids: Vec<String> = ["A", "B", "C", "D"].iter().map(|n| publish(&mut store, n).0).collect();
|
||||
// C gets a log with entries.
|
||||
let mut server = store.open(&ids[2]).unwrap();
|
||||
let mut c = join(&mut server, Access::Edit);
|
||||
store.changed(&ids[2], &mut server);
|
||||
rename(&mut server, &mut c, "Kept");
|
||||
store.changed(&ids[2], &mut server);
|
||||
rename(&mut server, &mut c, "Lost");
|
||||
store.changed(&ids[2], &mut server);
|
||||
store.flush();
|
||||
(ids[0].clone(), ids[1].clone(), ids[2].clone(), ids[3].clone())
|
||||
};
|
||||
let folder = |id: &str| dir.path().join(FILES).join(id);
|
||||
// A: a flipped byte in its snapshot; B: garbage description; C: its log cut mid-record;
|
||||
// D: a write that was cut short left a temporary file.
|
||||
let mut snap = fs::read(folder(&bad_snapshot).join(SNAPSHOT)).unwrap();
|
||||
let last = snap.len() - 1;
|
||||
snap[last] ^= 0xFF;
|
||||
fs::write(folder(&bad_snapshot).join(SNAPSHOT), snap).unwrap();
|
||||
fs::write(folder(&bad_meta).join(META), b"{not json").unwrap();
|
||||
let log = fs::read(folder(&cut_log).join(LOG)).unwrap();
|
||||
fs::write(folder(&cut_log).join(LOG), &log[..log.len() - 3]).unwrap();
|
||||
fs::write(disk::temp_of(&folder(&good).join(SNAPSHOT)), b"half").unwrap();
|
||||
fs::create_dir_all(dir.path().join(FILES).join("not a file id")).unwrap();
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
let problems = store.problems().join("\n");
|
||||
assert!(problems.contains(&bad_snapshot) && problems.contains("checksum"), "{problems}");
|
||||
assert!(problems.contains(&bad_meta) && problems.contains(META), "{problems}");
|
||||
assert!(problems.contains("not a file id"), "{problems}");
|
||||
assert_eq!(store.len(), 2, "C and D are served");
|
||||
// Nothing is deleted.
|
||||
assert!(folder(&bad_snapshot).exists() && folder(&bad_meta).exists());
|
||||
assert!(!disk::temp_of(&folder(&good).join(SNAPSHOT)).exists(), "the leftover is gone");
|
||||
// C opens without the cut-short record, and says so.
|
||||
let server = store.open(&cut_log).unwrap();
|
||||
let page = server.document().first_page().unwrap();
|
||||
assert_eq!(server.document().node(page).unwrap().name, "Kept");
|
||||
assert!(store.problems().iter().any(|p| p.contains(&cut_log) && p.contains("cut short")));
|
||||
// Damage after startup: the file can't be opened, with a reason for the person joining.
|
||||
fs::write(folder(&good).join(SNAPSHOT), b"FCSN garbage").unwrap();
|
||||
assert!(store.open(&good).unwrap_err().contains("can't be opened"));
|
||||
assert!(store.problems().iter().any(|p| p.contains(&good)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_hold() {
|
||||
let dir = TempDir::new("caps");
|
||||
let c = StoreConfig { max_files: 1, max_links: 3, ..cfg(dir.path()) };
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "One");
|
||||
assert!(store.publish("Two", &snapshot(doc("Two"))).unwrap_err().contains("at most 1"));
|
||||
store.create_link(&id, Access::View, "").unwrap();
|
||||
assert!(store.create_link(&id, Access::View, "").unwrap_err().contains("at most 3"));
|
||||
let dir2 = TempDir::new("caps-size");
|
||||
let mut tiny = Store::open(dir2.path(), StoreConfig { max_file_bytes: 64, ..cfg(dir2.path()) }).unwrap();
|
||||
assert!(tiny.publish("Big", &snapshot(doc("Big"))).unwrap_err().contains("MiB"));
|
||||
assert!(tiny.publish("Junk", &[figcraft_collab::Change::Delete { id: figcraft_doc::NodeId(1) }]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_server_per_data_directory() {
|
||||
let dir = TempDir::new("lock");
|
||||
let first = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
assert!(Store::open(dir.path(), cfg(dir.path())).err().unwrap().contains("is using"));
|
||||
assert!(Store::try_open(dir.path(), cfg(dir.path())).unwrap().is_none());
|
||||
let key = first.key().to_string();
|
||||
drop(first);
|
||||
let again = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
assert_eq!(again.key(), key, "the key is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_directories_are_removed() {
|
||||
let path = {
|
||||
let dir = TempDir::new("cleanup");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
publish(&mut store, "Gone");
|
||||
drop(store);
|
||||
dir.path().to_path_buf()
|
||||
};
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic_the_readers() {
|
||||
let mut rng = Rng::new(7);
|
||||
let state = Server::new(doc("Fuzz"), "", figcraft_collab::Limits::default()).state().unwrap();
|
||||
let snap = disk::encode_snapshot(3, &state).unwrap();
|
||||
let mut log = disk::log_header(3);
|
||||
log.extend(disk::record(&figcraft_collab::Journal::Issued { client: 9 }).unwrap());
|
||||
assert_eq!(disk::decode_snapshot(&snap).unwrap().1, state);
|
||||
assert_eq!(disk::read_log(&log).unwrap().entries.len(), 1);
|
||||
for round in 0..5_000 {
|
||||
let base = if round % 2 == 0 { &snap } else { &log };
|
||||
let mut bytes = base.clone();
|
||||
for _ in 0..1 + rng.below(4) {
|
||||
if bytes.is_empty() {
|
||||
break;
|
||||
}
|
||||
let i = rng.below(bytes.len());
|
||||
match rng.below(3) {
|
||||
0 => bytes[i] = rng.next_u64() as u8,
|
||||
1 => bytes.insert(i, rng.next_u64() as u8),
|
||||
_ => bytes.truncate(i),
|
||||
}
|
||||
}
|
||||
let _ = disk::decode_snapshot(&bytes);
|
||||
let _ = disk::check_snapshot(&bytes);
|
||||
let _ = disk::read_log(&bytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
//! What the tests share: temporary data directories (removed when dropped), settings, and engine
|
||||
//! sessions linked over loopback.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab_net::NetLink;
|
||||
use figcraft_collab_net::guest::GuestConfig;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// A directory of its own under the system's temporary directory, removed with everything in it
|
||||
/// when dropped.
|
||||
pub struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
pub fn new(tag: &str) -> TempDir {
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos());
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join("figcraft-server-tests").join(format!("{tag}-{}-{nanos}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TempDir(dir)
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings for a server on `dir`, listening on a free loopback port.
|
||||
pub fn config(dir: &Path) -> Config {
|
||||
Config { listen: "127.0.0.1:0".into(), data: dir.to_path_buf(), ..Config::default() }
|
||||
}
|
||||
|
||||
/// Joins quickly, and keeps trying long enough to outlast a server restart.
|
||||
pub fn quick(mut c: GuestConfig) -> GuestConfig {
|
||||
c.backoff = Duration::from_millis(20);
|
||||
c.max_backoff = Duration::from_millis(200);
|
||||
c.attempts = 100;
|
||||
c.limits.hello_timeout = Duration::from_secs(5);
|
||||
c
|
||||
}
|
||||
|
||||
/// An engine session with the network link, named `name`.
|
||||
pub fn session(name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
|
||||
s.execute("pref.userName", &json!({"name": name})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Pump every session (as their windows would each frame) until `f` holds.
|
||||
pub fn pump(sessions: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
for s in sessions.iter_mut() {
|
||||
s.live_poll();
|
||||
s.live_sync();
|
||||
}
|
||||
if f(sessions) {
|
||||
return;
|
||||
}
|
||||
let states: Vec<String> = sessions.iter().map(|s| s.live_summary().to_string()).collect();
|
||||
assert!(start.elapsed() < Duration::from_secs(20), "timed out waiting for {what}: {states:#?}");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// The synced properties of two documents agree.
|
||||
pub fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool {
|
||||
figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty())
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ impl<S: Socket> Driver<S> {
|
|||
pub(crate) fn closed(&mut self, code: u16, reason: &str, now: Instant) {
|
||||
let Some(opened) = self.conn.as_ref().map(|c| c.opened) else { return };
|
||||
let outcome = match closed(code, reason) {
|
||||
Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.host)),
|
||||
Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())),
|
||||
Outcome::Retry(_) if code == 1006 => Outcome::Retry("the connection dropped".into()),
|
||||
o => o,
|
||||
};
|
||||
|
|
@ -206,7 +206,7 @@ impl<S: Socket> Driver<S> {
|
|||
let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
|
||||
if !opened {
|
||||
if now >= started + self.cfg.connect_timeout {
|
||||
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.host)), now);
|
||||
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ impl Socket for Script {
|
|||
}
|
||||
|
||||
fn invite() -> Invite {
|
||||
Invite { host: "127.0.0.1:47900".into(), file: FILE.into(), token: TOKEN.into() }
|
||||
Invite::new("127.0.0.1:47900", FILE, TOKEN)
|
||||
}
|
||||
|
||||
fn start(sock: Script, t0: Instant) -> (Driver<Script>, Inbox) {
|
||||
|
|
@ -363,7 +363,7 @@ fn joins_a_real_host_and_edits_with_it() {
|
|||
host_probe.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let invite = Invite { host: host.addr().to_string(), file: FILE.into(), token: TOKEN.into() };
|
||||
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let mut d = Driver::start(Loopback::default(), GuestConfig::new(invite, "Ada".into()), inbox.clone(), Instant::now());
|
||||
let mut events = Vec::new();
|
||||
|
|
|
|||
|
|
@ -4,26 +4,43 @@
|
|||
//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
|
||||
//! When the connection drops after having been live, it reconnects with growing pauses; a
|
||||
//! refusal or the host ending the session is final.
|
||||
//!
|
||||
//! Publishing ([`Guest::publish`]) first uploads the document to a server over a managing
|
||||
//! connection, then joins the new file with its edit link on the same thread.
|
||||
|
||||
use std::io;
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Instant;
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, Published, Status};
|
||||
use tungstenite::protocol::frame::coding::CloseCode;
|
||||
use tungstenite::protocol::{CloseFrame, Role as WsRole, WebSocket};
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
pub use crate::join::GuestConfig;
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
|
||||
use crate::net::{Duplex, config, shutdown};
|
||||
use crate::manage;
|
||||
use crate::net::{Duplex, config, dial, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// What [`Guest::publish`] uploads, and where.
|
||||
pub struct Publish {
|
||||
pub server: Endpoint,
|
||||
/// The server's key.
|
||||
pub key: String,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
pub doc: Arc<Document>,
|
||||
}
|
||||
|
||||
/// A joined (or joining) session. Dropping it leaves.
|
||||
pub struct Guest {
|
||||
cmd: Sender<Cmd>,
|
||||
|
|
@ -50,6 +67,37 @@ impl Guest {
|
|||
Ok(Guest { cmd: tx, thread: Some(thread) })
|
||||
}
|
||||
|
||||
/// Upload `job.doc` to the server as a new file, then join it with its edit link (returns at
|
||||
/// once: [`Event::Published`] with the file's links, then the session, arrive in `inbox`).
|
||||
/// `cfg` says how to join; its invite is replaced by the new file's.
|
||||
pub fn publish(job: Publish, cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
|
||||
let (tx, rx) = channel();
|
||||
let me = tx.clone();
|
||||
let thread = std::thread::Builder::new().name("live-publish".into()).spawn(move || {
|
||||
inbox.push(Event::Status(Status::Connecting));
|
||||
let (cfg, published) = match upload(&job, cfg) {
|
||||
Ok(done) => done,
|
||||
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
|
||||
};
|
||||
inbox.push(Event::Published(published));
|
||||
// Left while uploading: the file stays on the server, nobody joins it. Anything else
|
||||
// that came meanwhile (presence) waits for the session.
|
||||
let mut waiting = Vec::new();
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(Cmd::Leave) | Err(TryRecvError::Disconnected) => return,
|
||||
Ok(c) => waiting.push(c),
|
||||
Err(TryRecvError::Empty) => break,
|
||||
}
|
||||
}
|
||||
for c in waiting {
|
||||
let _ = me.send(c);
|
||||
}
|
||||
supervise(&cfg, &inbox, &rx, &me);
|
||||
})?;
|
||||
Ok(Guest { cmd: tx, thread: Some(thread) })
|
||||
}
|
||||
|
||||
/// Your latest presence.
|
||||
pub fn presence(&self, p: Presence) {
|
||||
let _ = self.cmd.send(Cmd::Presence(p));
|
||||
|
|
@ -90,26 +138,28 @@ struct Conn {
|
|||
sock: TcpStream,
|
||||
}
|
||||
|
||||
/// Upload the document; the join settings for the new file and its links.
|
||||
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
|
||||
let snapshot = Replica::new((*job.doc).clone()).snapshot().map_err(|e| e.to_string())?;
|
||||
let replies =
|
||||
manage::request(&job.server, &job.key, vec![Admin::Publish { name: job.name.clone(), snapshot }], &cfg.limits, cfg.connect_timeout)?;
|
||||
let (file, edit, view) = match replies.into_iter().next() {
|
||||
Some(AdminReply::Published { file, edit, view }) => (file, edit, view),
|
||||
Some(AdminReply::Failed { reason }) => return Err(reason),
|
||||
_ => return Err("the server didn't say where the file is".into()),
|
||||
};
|
||||
let link = |token: String| Invite { server: job.server.clone(), file: file.id.clone(), token };
|
||||
let (edit, view) = (link(edit.token), link(view.token));
|
||||
let published = Published { server: job.server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
|
||||
cfg.invite = edit;
|
||||
Ok((cfg, published))
|
||||
}
|
||||
|
||||
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
|
||||
let addrs: Vec<SocketAddr> = cfg.invite.host.to_socket_addrs().map_err(|e| format!("can't find {}: {e}", cfg.invite.host))?.collect();
|
||||
let mut last = format!("can't find {}", cfg.invite.host);
|
||||
let mut stream = None;
|
||||
for a in addrs {
|
||||
match TcpStream::connect_timeout(&a, cfg.connect_timeout) {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = format!("no answer from {a}: {e}"),
|
||||
}
|
||||
}
|
||||
let stream = stream.ok_or(last)?;
|
||||
let _ = stream.set_nodelay(true);
|
||||
stream.set_read_timeout(Some(cfg.limits.hello_timeout)).map_err(|e| e.to_string())?;
|
||||
stream.set_write_timeout(Some(cfg.limits.write_timeout)).map_err(|e| e.to_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 (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.host))?;
|
||||
.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)));
|
||||
reader.get_ref().set_read_timeout(Some(cfg.limits.idle_timeout)).map_err(|e| e.to_string())?;
|
||||
Ok(Conn { reader, writer, sock })
|
||||
|
|
|
|||
|
|
@ -1,40 +1,20 @@
|
|||
//! The session host the app embeds: a WebSocket server for one file that runs the collaboration
|
||||
//! core's [`Server`] (the ordering authority) and carries its messages.
|
||||
//!
|
||||
//! Threads: one accepts connections; each connection has a reader and a writer (see [`crate::net`]);
|
||||
//! one hub owns the [`Server`] and everything shared, fed by a channel. The person hosting takes
|
||||
//! part through the hub directly (no socket): their presence goes in with [`Host::presence`],
|
||||
//! what others do comes out through their [`Inbox`].
|
||||
//!
|
||||
//! On top of the core the hub checks the invite's file id, hands out colours and default names,
|
||||
//! throttles presence to about 30 updates a second per person (latest wins), tells a newcomer
|
||||
//! its own name and colour (a `PeerJoined` of itself, after its welcome) and where everyone
|
||||
//! already is, and disconnects clients that send too much, too fast, or can't keep up.
|
||||
//! The session host the app embeds: the shared [hub](crate::hub) serving one file, the one the
|
||||
//! app shares, with the session's single token. The person hosting is in it without a socket:
|
||||
//! their presence goes in with [`Host::presence`], their edits with [`Host::send`], what others do
|
||||
//! comes out through their [`Inbox`]. When the host stops, the session ends for everyone.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io;
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, channel, sync_channel};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg};
|
||||
use figcraft_collab::{Outgoing, Server};
|
||||
use figcraft_collab::message::{Access, ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg};
|
||||
use figcraft_collab::{Outgoing, Server, same_secret};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
|
||||
use tungstenite::protocol::frame::coding::CloseCode;
|
||||
use tungstenite::protocol::{CloseFrame, Role, WebSocket};
|
||||
use tungstenite::{Bytes, Message};
|
||||
use figcraft_engine::live::{Event, PEOPLE};
|
||||
|
||||
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name};
|
||||
use crate::inbox::Inbox;
|
||||
pub use crate::join::ENDED;
|
||||
use crate::net::{Bucket, Duplex, NetLimits, config, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// How long a closing connection waits for the peer to hang up.
|
||||
const LINGER: Duration = Duration::from_secs(2);
|
||||
use crate::net::NetLimits;
|
||||
|
||||
/// What a hosted session needs.
|
||||
pub struct HostConfig {
|
||||
|
|
@ -55,50 +35,34 @@ pub struct HostConfig {
|
|||
|
||||
/// A running session host. Dropping it stops it.
|
||||
pub struct Host {
|
||||
addr: SocketAddr,
|
||||
hosting: Hosting,
|
||||
me: Peer,
|
||||
hub: Sender<HubIn>,
|
||||
stop: Arc<AtomicBool>,
|
||||
open: Arc<AtomicUsize>,
|
||||
hub_thread: Option<JoinHandle<()>>,
|
||||
accept_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// A message for one connection's writer.
|
||||
enum Out {
|
||||
Bytes(Bytes),
|
||||
Close(CloseCode, String),
|
||||
/// The app's one file and its token: everyone with the invite edits.
|
||||
struct OneFile {
|
||||
file: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
enum HubIn {
|
||||
Join {
|
||||
conn: u64,
|
||||
hello: Hello,
|
||||
out: SyncSender<Out>,
|
||||
sock: TcpStream,
|
||||
},
|
||||
Msg {
|
||||
conn: u64,
|
||||
msg: ClientMsg,
|
||||
},
|
||||
Gone {
|
||||
conn: u64,
|
||||
},
|
||||
Local(Presence),
|
||||
LocalMsg(ClientMsg),
|
||||
/// Cut every connection without a word (tests: a network failure).
|
||||
#[cfg(test)]
|
||||
Cut,
|
||||
Stop,
|
||||
}
|
||||
impl Backend for OneFile {
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
if file != self.file {
|
||||
return Err("this invite is for another file or an earlier session".into());
|
||||
}
|
||||
if !same_secret(token, &self.token) {
|
||||
return Err("wrong session token".into());
|
||||
}
|
||||
Ok(Admission { access: Access::Edit, link: None })
|
||||
}
|
||||
|
||||
fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result<JoinHandle<()>> {
|
||||
std::thread::Builder::new().name(name.to_string()).spawn(f)
|
||||
}
|
||||
fn open(&mut self, _: &str) -> Result<Server, String> {
|
||||
Err("this invite is for another file or an earlier session".into())
|
||||
}
|
||||
|
||||
/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters.
|
||||
fn clean_name(s: &str) -> String {
|
||||
s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect()
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Ended
|
||||
}
|
||||
}
|
||||
|
||||
impl Host {
|
||||
|
|
@ -109,8 +73,8 @@ impl Host {
|
|||
Err(e) if e.kind() == io::ErrorKind::AddrInUse && cfg.any_port_if_taken => TcpListener::bind(SocketAddr::new(cfg.addr.ip(), 0))?,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let addr = listener.local_addr()?;
|
||||
let mut server = Server::new(cfg.doc, cfg.token.clone(), cfg.core);
|
||||
// The hub checks the token; the session itself keeps none.
|
||||
let mut server = Server::new(cfg.doc, "", cfg.core);
|
||||
let name = match clean_name(&cfg.name) {
|
||||
n if n.is_empty() => "Host".to_string(),
|
||||
n => n,
|
||||
|
|
@ -118,7 +82,7 @@ impl Host {
|
|||
let hello = Hello {
|
||||
protocol: PROTOCOL,
|
||||
file: cfg.file.clone(),
|
||||
token: cfg.token,
|
||||
token: String::new(),
|
||||
name,
|
||||
color: PEOPLE[0].to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
|
|
@ -134,40 +98,15 @@ impl Host {
|
|||
}
|
||||
}
|
||||
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;
|
||||
let (hub_tx, hub_rx) = channel();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
let hub = Hub {
|
||||
server,
|
||||
file: cfg.file,
|
||||
local: me.client_id,
|
||||
inbox,
|
||||
conns: HashMap::new(),
|
||||
ids: BTreeMap::new(),
|
||||
throttles: BTreeMap::new(),
|
||||
latest: BTreeMap::new(),
|
||||
guests: 0,
|
||||
};
|
||||
let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?;
|
||||
let accept_thread = {
|
||||
let (hub_tx, stop, open, limits) = (hub_tx.clone(), stop.clone(), open.clone(), cfg.limits);
|
||||
spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits))
|
||||
};
|
||||
let accept_thread = match accept_thread {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = hub_tx.send(HubIn::Stop);
|
||||
let _ = hub_thread.join();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
log::info!("live session: hosting on {addr}");
|
||||
Ok(Host { addr, me, hub: hub_tx, stop, open, hub_thread: Some(hub_thread), accept_thread: Some(accept_thread) })
|
||||
let backend = OneFile { file: cfg.file.clone(), token: cfg.token };
|
||||
let local = Local { file: cfg.file, server, id: me.client_id, inbox };
|
||||
let hosting = Hosting::start(listener, backend, cfg.limits, Some(local))?;
|
||||
Ok(Host { hosting, me })
|
||||
}
|
||||
|
||||
/// Where it listens.
|
||||
pub fn addr(&self) -> SocketAddr {
|
||||
self.addr
|
||||
self.hosting.addr()
|
||||
}
|
||||
|
||||
/// The host as the others see them.
|
||||
|
|
@ -177,447 +116,27 @@ impl Host {
|
|||
|
||||
/// Open connections (joined or not).
|
||||
pub fn connections(&self) -> usize {
|
||||
self.open.load(Ordering::Relaxed)
|
||||
self.hosting.connections()
|
||||
}
|
||||
|
||||
/// The host's own presence.
|
||||
pub fn presence(&self, p: Presence) {
|
||||
let _ = self.hub.send(HubIn::Local(p));
|
||||
self.hosting.presence(p);
|
||||
}
|
||||
|
||||
/// A message from the host's own sync client (its edits).
|
||||
pub fn send(&self, msg: ClientMsg) {
|
||||
let _ = self.hub.send(HubIn::LocalMsg(msg));
|
||||
self.hosting.send(msg);
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
let _ = self.hub.send(HubIn::Cut);
|
||||
self.hosting.cut_connections();
|
||||
}
|
||||
|
||||
/// End the session: everyone is told and disconnected, the port is released.
|
||||
pub fn stop(&mut self) {
|
||||
if self.stop.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let _ = self.hub.send(HubIn::Stop);
|
||||
if let Some(t) = self.hub_thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
// The accept loop checks the flag after each connection: wake it with one.
|
||||
let wake = if self.addr.ip().is_unspecified() { SocketAddr::from(([127, 0, 0, 1], self.addr.port())) } else { self.addr };
|
||||
let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok();
|
||||
if let Some(t) = self.accept_thread.take() {
|
||||
if woke {
|
||||
let _ = t.join();
|
||||
} else {
|
||||
log::warn!("live session: couldn't wake the accept loop; it ends with the next connection");
|
||||
}
|
||||
}
|
||||
// Readers end once their writers have closed the sockets.
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
log::info!("live session: stopped hosting");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Host {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts an open connection until dropped.
|
||||
struct Open(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Open {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, limits: NetLimits) {
|
||||
let mut next = 0u64;
|
||||
for stream in listener.incoming() {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let stream = match stream {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Out of sockets or similar: don't spin.
|
||||
log::warn!("live session: accept failed: {e}");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if open.load(Ordering::Relaxed) >= limits.max_connections {
|
||||
log::info!("live session: too many connections; refusing one");
|
||||
continue;
|
||||
}
|
||||
open.fetch_add(1, Ordering::Relaxed);
|
||||
let guard = Open(open.clone());
|
||||
next += 1;
|
||||
let (conn, hub) = (next, hub.clone());
|
||||
if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard)) {
|
||||
log::warn!("live session: can't start a connection thread: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A close reason that fits a WebSocket close frame (123 bytes).
|
||||
fn reason(s: &str) -> String {
|
||||
let mut end = s.len().min(123);
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s.get(..end).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
fn encode(msg: &ServerMsg) -> Option<Bytes> {
|
||||
match message::encode(msg) {
|
||||
Ok(b) => Some(Bytes::from(b)),
|
||||
Err(e) => {
|
||||
log::warn!("live session: can't encode a message: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue `Refused {reason}` and a close for one connection.
|
||||
fn refuse(out: &SyncSender<Out>, why: &str) {
|
||||
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
|
||||
let _ = out.try_send(Out::Bytes(b));
|
||||
}
|
||||
let _ = out.try_send(Out::Close(CloseCode::Policy, reason(why)));
|
||||
}
|
||||
|
||||
/// Read the next binary message (skipping pings), or why the connection should end.
|
||||
fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<ClientMsg>, &'static str> {
|
||||
loop {
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => message::decode::<ClientMsg>(&b, max).map(Some).map_err(|_| "that message couldn't be read"),
|
||||
Ok(Message::Text(_)) => Err("only binary messages are spoken here"),
|
||||
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
|
||||
Ok(Message::Close(_)) => Ok(None),
|
||||
Err(tungstenite::Error::Capacity(_)) => Err("message too large"),
|
||||
Err(_) => Ok(None),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection's reader (the writer runs on its own thread).
|
||||
fn serve(stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, _open: Open) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
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 cfg = config(limits.max_client_message);
|
||||
let mut ws = match tungstenite::accept_with_config(rd, Some(config(limits.max_hello))) {
|
||||
Ok(ws) => ws,
|
||||
Err(e) => {
|
||||
log::debug!("live session: handshake failed: {e}");
|
||||
shutdown(&sock);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (out, rx) = sync_channel::<Out>(limits.queue.max(4));
|
||||
let writer =
|
||||
match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) {
|
||||
Ok(Ok(t)) => t,
|
||||
_ => {
|
||||
shutdown(&sock);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let end = |why: Option<&str>| {
|
||||
if let Some(why) = why {
|
||||
refuse(&out, why);
|
||||
} else {
|
||||
let _ = out.try_send(Out::Close(CloseCode::Normal, String::new()));
|
||||
}
|
||||
};
|
||||
match next_message(&mut ws, limits.max_hello) {
|
||||
Ok(Some(ClientMsg::Hello(hello))) => {
|
||||
ws.set_config(|c| *c = cfg);
|
||||
let joined = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Join { conn, hello, out: out.clone(), sock: s }).is_ok());
|
||||
if joined && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() {
|
||||
read_loop(&mut ws, conn, hub, limits, &end);
|
||||
let _ = hub.send(HubIn::Gone { conn });
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => end(Some("say hello first")),
|
||||
Ok(None) => {}
|
||||
Err(why) => end(Some(why)),
|
||||
}
|
||||
linger(&mut ws);
|
||||
drop(out);
|
||||
// The writer closes the socket once it has sent what's queued (a refusal, a close).
|
||||
let _ = writer.join();
|
||||
shutdown(&sock);
|
||||
}
|
||||
|
||||
fn read_loop(ws: &mut WebSocket<Duplex>, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, end: &dyn Fn(Option<&str>)) {
|
||||
let mut bucket = Bucket::new(limits.rate, limits.burst);
|
||||
loop {
|
||||
match next_message(ws, limits.max_client_message) {
|
||||
Ok(Some(msg)) => {
|
||||
if !bucket.take(Instant::now()) {
|
||||
log::info!("live session: connection {conn} sends too much; disconnecting it");
|
||||
return end(Some("too many messages"));
|
||||
}
|
||||
if hub.send(HubIn::Msg { conn, msg }).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => return end(None),
|
||||
Err(why) => return end(Some(why)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_loop(mut ws: WebSocket<Duplex>, rx: &Receiver<Out>, sock: &TcpStream) {
|
||||
let mut close = Some((CloseCode::Normal, String::new()));
|
||||
while let Ok(o) = rx.recv() {
|
||||
match o {
|
||||
Out::Bytes(b) => {
|
||||
if ws.send(Message::Binary(b)).is_err() {
|
||||
close = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Out::Close(code, why) => {
|
||||
close = Some((code, why));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((code, why)) = close {
|
||||
// Close politely: the close frame, then no more writing; the reader drains what the peer
|
||||
// still sends until it hangs up (closing on unread data would reset the connection and
|
||||
// could lose the close frame), or until the linger runs out.
|
||||
let _ = ws.close(Some(CloseFrame { code, reason: why.into() }));
|
||||
let _ = sock.shutdown(std::net::Shutdown::Write);
|
||||
let until = Instant::now() + LINGER;
|
||||
while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
|
||||
if Instant::now() >= until {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
shutdown(sock);
|
||||
}
|
||||
|
||||
/// Read and drop what the peer still sends after the close, until it hangs up (at most
|
||||
/// [`LINGER`]).
|
||||
fn linger(ws: &mut WebSocket<Duplex>) {
|
||||
let until = Instant::now() + LINGER;
|
||||
while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() {
|
||||
match ws.read() {
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A joined or joining connection.
|
||||
struct Conn {
|
||||
out: SyncSender<Out>,
|
||||
sock: TcpStream,
|
||||
id: u32,
|
||||
}
|
||||
|
||||
/// Owns the collaboration server and routes its messages.
|
||||
struct Hub {
|
||||
server: Server,
|
||||
file: String,
|
||||
/// The host's own client id.
|
||||
local: u32,
|
||||
inbox: Inbox,
|
||||
conns: HashMap<u64, Conn>,
|
||||
/// Client id → connection.
|
||||
ids: BTreeMap<u32, u64>,
|
||||
throttles: BTreeMap<u32, Throttle<Presence>>,
|
||||
/// Everyone's latest presence (for newcomers).
|
||||
latest: BTreeMap<u32, Presence>,
|
||||
/// Default names handed out ("Guest 1", "Guest 2"…).
|
||||
guests: u32,
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
fn run(mut self, rx: &Receiver<HubIn>) {
|
||||
loop {
|
||||
let due = earliest(self.throttles.values().map(Throttle::due));
|
||||
let msg = match due {
|
||||
Some(d) => match rx.recv_timeout(d.saturating_duration_since(Instant::now())) {
|
||||
Ok(m) => Some(m),
|
||||
Err(RecvTimeoutError::Timeout) => None,
|
||||
Err(RecvTimeoutError::Disconnected) => Some(HubIn::Stop),
|
||||
},
|
||||
None => Some(rx.recv().unwrap_or(HubIn::Stop)),
|
||||
};
|
||||
match msg {
|
||||
Some(HubIn::Stop) => break,
|
||||
Some(m) => self.handle(m),
|
||||
None => {}
|
||||
}
|
||||
self.flush_due(Instant::now());
|
||||
}
|
||||
for c in self.conns.values() {
|
||||
if c.out.try_send(Out::Close(CloseCode::Away, ENDED.into())).is_err() {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, m: HubIn) {
|
||||
match m {
|
||||
HubIn::Join { conn, hello, out, sock } => self.join(conn, hello, out, sock),
|
||||
HubIn::Msg { conn, msg } => {
|
||||
let Some(id) = self.conns.get(&conn).map(|c| c.id) else { return };
|
||||
match msg {
|
||||
ClientMsg::Presence(p) => self.presence(id, p),
|
||||
other => {
|
||||
let outs = self.server.receive(id, other);
|
||||
self.deliver(outs);
|
||||
}
|
||||
}
|
||||
}
|
||||
HubIn::Gone { conn } => self.gone(conn),
|
||||
HubIn::Local(p) => self.presence(self.local, p),
|
||||
HubIn::LocalMsg(ClientMsg::Presence(p)) => self.presence(self.local, p),
|
||||
HubIn::LocalMsg(msg) => {
|
||||
let outs = self.server.receive(self.local, msg);
|
||||
self.deliver(outs);
|
||||
}
|
||||
#[cfg(test)]
|
||||
HubIn::Cut => {
|
||||
for c in self.conns.values() {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
}
|
||||
HubIn::Stop => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn join(&mut self, conn: u64, mut hello: Hello, out: SyncSender<Out>, sock: TcpStream) {
|
||||
if hello.file != self.file {
|
||||
return refuse(&out, "this invite is for another file or an earlier session");
|
||||
}
|
||||
// Colours: the first one nobody has.
|
||||
let taken: Vec<&str> = self.server.peers().map(|p| p.color.as_str()).collect();
|
||||
let color = PEOPLE.iter().find(|c| !taken.contains(c)).copied().unwrap_or(PEOPLE[taken.len() % PEOPLE.len()]);
|
||||
hello.color = color.to_string();
|
||||
hello.name = match clean_name(&hello.name) {
|
||||
n if n.is_empty() => {
|
||||
self.guests += 1;
|
||||
format!("Guest {}", self.guests)
|
||||
}
|
||||
n => n,
|
||||
};
|
||||
match self.server.join(hello) {
|
||||
Ok((id, outs)) => {
|
||||
// A guest back after a drop keeps its id: its old connection, if the hub hasn't
|
||||
// noticed it's gone yet, is closed without the guest leaving.
|
||||
if let Some(old) = self.ids.get(&id).copied().filter(|old| *old != conn)
|
||||
&& let Some(c) = self.conns.remove(&old)
|
||||
{
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
self.conns.insert(conn, Conn { out, sock, id });
|
||||
self.ids.insert(id, conn);
|
||||
self.deliver(outs);
|
||||
// The newcomer learns how it is shown, then where everyone is.
|
||||
let mut more = Vec::new();
|
||||
if let Some(me) = self.server.peers().find(|p| p.client_id == id) {
|
||||
more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone())));
|
||||
}
|
||||
more.extend(self.latest.iter().map(|(&from, p)| Outgoing::To(id, ServerMsg::Presence { client_id: from, presence: p.clone() })));
|
||||
self.deliver(more);
|
||||
}
|
||||
Err(ServerMsg::Refused { reason }) => refuse(&out, &reason),
|
||||
Err(_) => refuse(&out, "refused"),
|
||||
}
|
||||
}
|
||||
|
||||
fn gone(&mut self, conn: u64) {
|
||||
let Some(c) = self.conns.remove(&conn) else { return };
|
||||
self.ids.remove(&c.id);
|
||||
self.throttles.remove(&c.id);
|
||||
self.latest.remove(&c.id);
|
||||
let outs = self.server.leave(c.id);
|
||||
self.deliver(outs);
|
||||
}
|
||||
|
||||
fn presence(&mut self, id: u32, p: Presence) {
|
||||
let p = sanitize(p);
|
||||
self.latest.insert(id, p.clone());
|
||||
let now = Instant::now();
|
||||
if let Some(p) = self.throttles.entry(id).or_insert_with(|| Throttle::new(PRESENCE_INTERVAL)).offer(p, now) {
|
||||
let outs = self.server.receive(id, ClientMsg::Presence(p));
|
||||
self.deliver(outs);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_due(&mut self, now: Instant) {
|
||||
let due: Vec<(u32, Presence)> = self.throttles.iter_mut().filter_map(|(id, t)| t.take_due(now).map(|p| (*id, p))).collect();
|
||||
for (id, p) in due {
|
||||
let outs = self.server.receive(id, ClientMsg::Presence(p));
|
||||
self.deliver(outs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send what the server said; connections that can't keep up are dropped.
|
||||
fn deliver(&mut self, outs: Vec<Outgoing>) {
|
||||
let mut stuck = Vec::new();
|
||||
for o in outs {
|
||||
let (to, msg): (Vec<u32>, ServerMsg) = match o {
|
||||
Outgoing::To(id, m) => (vec![id], m),
|
||||
Outgoing::All(m) => (self.server.peers().map(|p| p.client_id).collect(), m),
|
||||
Outgoing::Others(x, m) => (self.server.peers().map(|p| p.client_id).filter(|id| *id != x).collect(), m),
|
||||
};
|
||||
let mut bytes = None;
|
||||
for id in to {
|
||||
if id == self.local {
|
||||
self.to_local(&msg);
|
||||
continue;
|
||||
}
|
||||
let Some(c) = self.ids.get(&id).and_then(|conn| self.conns.get(conn)) else { continue };
|
||||
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
|
||||
match c.out.try_send(Out::Bytes(b)) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_)) => {
|
||||
log::info!("live session: client {id} can't keep up; disconnecting it");
|
||||
stuck.push(id);
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => stuck.push(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in stuck {
|
||||
if let Some(&conn) = self.ids.get(&id) {
|
||||
if let Some(c) = self.conns.get(&conn) {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
self.gone(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the host's own window should know.
|
||||
fn to_local(&self, msg: &ServerMsg) {
|
||||
match msg {
|
||||
ServerMsg::PeerJoined(p) if p.client_id != self.local => self.inbox.push(Event::PeerJoined(p.clone())),
|
||||
ServerMsg::PeerLeft { client_id } => self.inbox.push(Event::PeerLeft(*client_id)),
|
||||
ServerMsg::Presence { client_id, presence } => self.inbox.presence(*client_id, presence.clone()),
|
||||
ServerMsg::Changes { .. } | ServerMsg::Reject { .. } => self.inbox.push(Event::Sync(msg.clone())),
|
||||
_ => {}
|
||||
}
|
||||
self.hosting.stop();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,842 @@
|
|||
//! The hub every host runs, for one file (a session the app hosts) or many (`figcraft-server`):
|
||||
//! a WebSocket listener, a reader and a writer thread per connection, and one hub thread that owns
|
||||
//! a collaboration core [`Server`] per open file and routes its messages.
|
||||
//!
|
||||
//! What differs between the two lives behind a [`Backend`]: which hellos it admits and with what
|
||||
//! access, where a file that isn't open comes from, what is kept of the changes (a server appends
|
||||
//! them to its log; the app keeps nothing), and the managing connections a server answers. The
|
||||
//! person hosting an app session takes part through the hub directly (no socket): their presence
|
||||
//! and edits go in with [`Hosting::presence`] and [`Hosting::send`], what others do comes out
|
||||
//! through their [`Inbox`].
|
||||
//!
|
||||
//! On top of the core the hub hands out colours and default names per file, throttles presence to
|
||||
//! about 30 updates a second per person (latest wins), tells a newcomer its own name and colour (a
|
||||
//! `PeerJoined` of itself, after its welcome) and where everyone already is, and disconnects
|
||||
//! clients that send too much, too fast, or can't keep up. Whatever the servers say goes out only
|
||||
//! after the backend has made what changed durable ([`Backend::flush`], once per burst of
|
||||
//! messages), so a confirmed edit is never lost to a crash.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io;
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError, channel, sync_channel};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, Hello, Manage, PROTOCOL, Presence, ServerMsg};
|
||||
use figcraft_collab::{Outgoing, Server};
|
||||
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
|
||||
use tungstenite::protocol::frame::coding::CloseCode;
|
||||
use tungstenite::protocol::{CloseFrame, Role, WebSocket};
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::join::{ENDED, RESTARTING};
|
||||
use crate::net::{Bucket, Duplex, NetLimits, config, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// How long a closing connection waits for the peer to hang up.
|
||||
const LINGER: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Most messages the hub handles before it sends what they produced (bounds the delay a burst
|
||||
/// adds).
|
||||
const BURST: usize = 256;
|
||||
|
||||
/// What a link lets a hello do.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Admission {
|
||||
pub access: Access,
|
||||
/// The link's id, when the backend has several per file (revoking it disconnects whoever
|
||||
/// joined with it).
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
/// An admin request's reply, and what the hub must do about connections it affects.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Handled {
|
||||
pub reply: AdminReply,
|
||||
/// A link was revoked: `(file, link)`; whoever joined with it is disconnected.
|
||||
pub revoked: Option<(String, String)>,
|
||||
/// A file was removed: whoever is in it is disconnected and it is closed without saving.
|
||||
pub removed: Option<String>,
|
||||
}
|
||||
|
||||
impl From<AdminReply> for Handled {
|
||||
fn from(reply: AdminReply) -> Self {
|
||||
Self { reply, revoked: None, removed: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// What everyone is told when a hub stops.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Closing {
|
||||
/// The session is over (the app stopped hosting): final.
|
||||
Ended,
|
||||
/// The server is restarting: clients reconnect until it is back.
|
||||
Restarting,
|
||||
}
|
||||
|
||||
/// What the hub knows of an open file (for listings).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OpenFile {
|
||||
/// People connected to it.
|
||||
pub people: u32,
|
||||
/// Its latest sequence number.
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it
|
||||
/// from its own thread only.
|
||||
pub trait Backend: Send + 'static {
|
||||
/// A hello for `file` with `token`: what it may do, or why it may not join (the reason is
|
||||
/// shown to the person joining; never echo the token).
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String>;
|
||||
|
||||
/// The session for a file that isn't open (after [`admit`](Self::admit) let someone in).
|
||||
fn open(&mut self, file: &str) -> Result<Server, String>;
|
||||
|
||||
/// The hub handled a message for an open file: keep what changed (its server's journal).
|
||||
fn changed(&mut self, _file: &str, _server: &mut Server) {}
|
||||
|
||||
/// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers
|
||||
/// after this.
|
||||
fn flush(&mut self) {}
|
||||
|
||||
/// Everyone left `file`: save it. True closes it (the next hello opens it again).
|
||||
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The hub is stopping: save the open files.
|
||||
fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {}
|
||||
|
||||
/// A managing connection's key: whether it may manage files.
|
||||
fn manage(&mut self, _key: &str) -> Result<(), String> {
|
||||
Err("this session is hosted by an app: it has no files to manage".into())
|
||||
}
|
||||
|
||||
/// A request on a managing connection; `open` tells about a file that is open.
|
||||
fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
AdminReply::Failed { reason: "nothing to manage here".into() }.into()
|
||||
}
|
||||
|
||||
/// What everyone is told when the hub stops.
|
||||
fn closing(&self) -> Closing;
|
||||
}
|
||||
|
||||
/// A message for one connection's writer.
|
||||
enum Out {
|
||||
Bytes(Bytes),
|
||||
Close(CloseCode, String),
|
||||
}
|
||||
|
||||
enum HubIn {
|
||||
/// A connection's first message (`Hello` or `Manage`).
|
||||
Open {
|
||||
conn: u64,
|
||||
first: ClientMsg,
|
||||
out: SyncSender<Out>,
|
||||
sock: TcpStream,
|
||||
},
|
||||
Msg {
|
||||
conn: u64,
|
||||
msg: ClientMsg,
|
||||
},
|
||||
Gone {
|
||||
conn: u64,
|
||||
},
|
||||
Local(Presence),
|
||||
LocalMsg(ClientMsg),
|
||||
/// Cut every connection without a word (tests: a network failure).
|
||||
#[cfg(test)]
|
||||
Cut,
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// The person hosting an app session: in the file's session without a socket.
|
||||
pub(crate) struct Local {
|
||||
pub file: String,
|
||||
pub server: Server,
|
||||
/// Their client id (they joined `server` already).
|
||||
pub id: u32,
|
||||
pub inbox: Inbox,
|
||||
}
|
||||
|
||||
fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result<JoinHandle<()>> {
|
||||
std::thread::Builder::new().name(name.to_string()).spawn(f)
|
||||
}
|
||||
|
||||
/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters.
|
||||
pub(crate) fn clean_name(s: &str) -> String {
|
||||
s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect()
|
||||
}
|
||||
|
||||
/// A running hub: its listener, connections and hub thread. Dropping it stops it.
|
||||
pub struct Hosting {
|
||||
addr: SocketAddr,
|
||||
hub: Sender<HubIn>,
|
||||
stop: Arc<AtomicBool>,
|
||||
open: Arc<AtomicUsize>,
|
||||
hub_thread: Option<JoinHandle<()>>,
|
||||
accept_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Hosting {
|
||||
/// Serve `backend`'s files on `listener`.
|
||||
pub fn serve<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result<Hosting> {
|
||||
Self::start(listener, backend, limits, None)
|
||||
}
|
||||
|
||||
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
|
||||
let addr = listener.local_addr()?;
|
||||
let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new() };
|
||||
if let Some(l) = local {
|
||||
let mut room = Room::new(l.server);
|
||||
room.local = Some((l.id, l.inbox));
|
||||
hub.rooms.insert(l.file.clone(), room);
|
||||
hub.local = Some(l.file);
|
||||
}
|
||||
let (hub_tx, hub_rx) = channel();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?;
|
||||
let accept_thread = {
|
||||
let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone());
|
||||
spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits))
|
||||
};
|
||||
let accept_thread = match accept_thread {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = hub_tx.send(HubIn::Stop);
|
||||
let _ = hub_thread.join();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
log::info!("live session: listening on {addr}");
|
||||
Ok(Hosting { addr, hub: hub_tx, stop, open, hub_thread: Some(hub_thread), accept_thread: Some(accept_thread) })
|
||||
}
|
||||
|
||||
/// Where it listens.
|
||||
pub fn addr(&self) -> SocketAddr {
|
||||
self.addr
|
||||
}
|
||||
|
||||
/// Open connections (joined or not).
|
||||
pub fn connections(&self) -> usize {
|
||||
self.open.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The hosting person's presence (app sessions).
|
||||
pub(crate) fn presence(&self, p: Presence) {
|
||||
let _ = self.hub.send(HubIn::Local(p));
|
||||
}
|
||||
|
||||
/// A message from the hosting person's sync client (their edits).
|
||||
pub(crate) fn send(&self, msg: ClientMsg) {
|
||||
let _ = self.hub.send(HubIn::LocalMsg(msg));
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
let _ = self.hub.send(HubIn::Cut);
|
||||
}
|
||||
|
||||
/// Stop: the backend saves what's open, everyone is told and disconnected, the port is
|
||||
/// released.
|
||||
pub fn stop(&mut self) {
|
||||
if self.stop.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let _ = self.hub.send(HubIn::Stop);
|
||||
if let Some(t) = self.hub_thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
// The accept loop checks the flag after each connection: wake it with one.
|
||||
let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr };
|
||||
let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok();
|
||||
if let Some(t) = self.accept_thread.take() {
|
||||
if woke {
|
||||
let _ = t.join();
|
||||
} else {
|
||||
log::warn!("live session: couldn't wake the accept loop; it ends with the next connection");
|
||||
}
|
||||
}
|
||||
// Readers end once their writers have closed the sockets.
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
log::info!("live session: stopped listening on {}", self.addr);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Hosting {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The loopback address of `addr`'s family.
|
||||
fn loopback(addr: SocketAddr) -> std::net::IpAddr {
|
||||
if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() }
|
||||
}
|
||||
|
||||
/// Counts an open connection until dropped.
|
||||
struct Open(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Open {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, limits: NetLimits) {
|
||||
let mut next = 0u64;
|
||||
for stream in listener.incoming() {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let stream = match stream {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Out of sockets or similar: don't spin.
|
||||
log::warn!("live session: accept failed: {e}");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if open.load(Ordering::Relaxed) >= limits.max_connections {
|
||||
log::info!("live session: too many connections; refusing one");
|
||||
continue;
|
||||
}
|
||||
open.fetch_add(1, Ordering::Relaxed);
|
||||
let guard = Open(open.clone());
|
||||
next += 1;
|
||||
let (conn, hub) = (next, hub.clone());
|
||||
if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard)) {
|
||||
log::warn!("live session: can't start a connection thread: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A close reason that fits a WebSocket close frame (123 bytes).
|
||||
fn reason(s: &str) -> String {
|
||||
let mut end = s.len().min(123);
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s.get(..end).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
fn encode(msg: &ServerMsg) -> Option<Bytes> {
|
||||
match message::encode(msg) {
|
||||
Ok(b) => Some(Bytes::from(b)),
|
||||
Err(e) => {
|
||||
log::warn!("live session: can't encode a message: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue `Refused {reason}` and a close for one connection.
|
||||
fn refuse(out: &SyncSender<Out>, why: &str) {
|
||||
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
|
||||
let _ = out.try_send(Out::Bytes(b));
|
||||
}
|
||||
let _ = out.try_send(Out::Close(CloseCode::Policy, reason(why)));
|
||||
}
|
||||
|
||||
/// Read the next binary message (skipping pings), or why the connection should end.
|
||||
fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<ClientMsg>, &'static str> {
|
||||
loop {
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => message::decode::<ClientMsg>(&b, max).map(Some).map_err(|_| "that message couldn't be read"),
|
||||
Ok(Message::Text(_)) => Err("only binary messages are spoken here"),
|
||||
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
|
||||
Ok(Message::Close(_)) => Ok(None),
|
||||
Err(tungstenite::Error::Capacity(_)) => Err("message too large"),
|
||||
Err(_) => Ok(None),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection's reader (the writer runs on its own thread).
|
||||
fn serve(stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, _open: Open) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
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 cfg = config(limits.max_client_message);
|
||||
let mut ws = match tungstenite::accept_with_config(rd, Some(config(limits.max_hello))) {
|
||||
Ok(ws) => ws,
|
||||
Err(e) => {
|
||||
log::debug!("live session: handshake failed: {e}");
|
||||
shutdown(&sock);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (out, rx) = sync_channel::<Out>(limits.queue.max(4));
|
||||
let writer =
|
||||
match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) {
|
||||
Ok(Ok(t)) => t,
|
||||
_ => {
|
||||
shutdown(&sock);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let end = |why: Option<&str>| {
|
||||
if let Some(why) = why {
|
||||
refuse(&out, why);
|
||||
} else {
|
||||
let _ = out.try_send(Out::Close(CloseCode::Normal, String::new()));
|
||||
}
|
||||
};
|
||||
match next_message(&mut ws, limits.max_hello) {
|
||||
Ok(Some(first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_)))) => {
|
||||
ws.set_config(|c| *c = cfg);
|
||||
let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, first, out: out.clone(), sock: s }).is_ok());
|
||||
if opened && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() {
|
||||
read_loop(&mut ws, conn, hub, limits, &end);
|
||||
let _ = hub.send(HubIn::Gone { conn });
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => end(Some("say hello first")),
|
||||
Ok(None) => {}
|
||||
Err(why) => end(Some(why)),
|
||||
}
|
||||
linger(&mut ws);
|
||||
drop(out);
|
||||
// The writer closes the socket once it has sent what's queued (a refusal, a close).
|
||||
let _ = writer.join();
|
||||
shutdown(&sock);
|
||||
}
|
||||
|
||||
fn read_loop(ws: &mut WebSocket<Duplex>, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, end: &dyn Fn(Option<&str>)) {
|
||||
let mut bucket = Bucket::new(limits.rate, limits.burst);
|
||||
loop {
|
||||
match next_message(ws, limits.max_client_message) {
|
||||
Ok(Some(msg)) => {
|
||||
if !bucket.take(Instant::now()) {
|
||||
log::info!("live session: connection {conn} sends too much; disconnecting it");
|
||||
return end(Some("too many messages"));
|
||||
}
|
||||
if hub.send(HubIn::Msg { conn, msg }).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => return end(None),
|
||||
Err(why) => return end(Some(why)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_loop(mut ws: WebSocket<Duplex>, rx: &Receiver<Out>, sock: &TcpStream) {
|
||||
let mut close = Some((CloseCode::Normal, String::new()));
|
||||
while let Ok(o) = rx.recv() {
|
||||
match o {
|
||||
Out::Bytes(b) => {
|
||||
if ws.send(Message::Binary(b)).is_err() {
|
||||
close = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Out::Close(code, why) => {
|
||||
close = Some((code, why));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((code, why)) = close {
|
||||
// Close politely: the close frame, then no more writing; the reader drains what the peer
|
||||
// still sends until it hangs up (closing on unread data would reset the connection and
|
||||
// could lose the close frame), or until the linger runs out.
|
||||
let _ = ws.close(Some(CloseFrame { code, reason: why.into() }));
|
||||
let _ = sock.shutdown(std::net::Shutdown::Write);
|
||||
let until = Instant::now() + LINGER;
|
||||
while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
|
||||
if Instant::now() >= until {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
shutdown(sock);
|
||||
}
|
||||
|
||||
/// Read and drop what the peer still sends after the close, until it hangs up (at most
|
||||
/// [`LINGER`]).
|
||||
fn linger(ws: &mut WebSocket<Duplex>) {
|
||||
let until = Instant::now() + LINGER;
|
||||
while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() {
|
||||
match ws.read() {
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a connection is for.
|
||||
enum Kind {
|
||||
/// In `file`'s session as client `id`, through link `link`.
|
||||
Joined { file: String, id: u32, link: Option<String> },
|
||||
/// Managing the server's files.
|
||||
Managing,
|
||||
}
|
||||
|
||||
/// A joined or managing connection.
|
||||
struct Conn {
|
||||
out: SyncSender<Out>,
|
||||
sock: TcpStream,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
/// One open file's session.
|
||||
struct Room {
|
||||
server: Server,
|
||||
/// Client id → connection.
|
||||
ids: BTreeMap<u32, u64>,
|
||||
throttles: BTreeMap<u32, Throttle<Presence>>,
|
||||
/// Everyone's latest presence (for newcomers).
|
||||
latest: BTreeMap<u32, Presence>,
|
||||
/// Default names handed out ("Guest 1", "Guest 2"…).
|
||||
guests: u32,
|
||||
/// The person hosting (app sessions): their client id and inbox.
|
||||
local: Option<(u32, Inbox)>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
fn new(server: Server) -> Self {
|
||||
Self { server, ids: BTreeMap::new(), throttles: BTreeMap::new(), latest: BTreeMap::new(), guests: 0, local: None }
|
||||
}
|
||||
|
||||
/// Nobody is in it.
|
||||
fn empty(&self) -> bool {
|
||||
self.ids.is_empty() && self.local.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the open files' servers and routes their messages.
|
||||
struct Hub<B> {
|
||||
backend: B,
|
||||
rooms: HashMap<String, Room>,
|
||||
conns: HashMap<u64, Conn>,
|
||||
/// The file the person hosting is in (app sessions).
|
||||
local: Option<String>,
|
||||
/// What the servers said, sent once the backend made what changed durable.
|
||||
outbox: Vec<(String, Outgoing)>,
|
||||
}
|
||||
|
||||
impl<B: Backend> Hub<B> {
|
||||
fn run(mut self, rx: &Receiver<HubIn>) {
|
||||
'run: loop {
|
||||
let due = earliest(self.rooms.values().flat_map(|r| r.throttles.values().map(Throttle::due)));
|
||||
let first = match due {
|
||||
Some(d) => match rx.recv_timeout(d.saturating_duration_since(Instant::now())) {
|
||||
Ok(m) => Some(m),
|
||||
Err(RecvTimeoutError::Timeout) => None,
|
||||
Err(RecvTimeoutError::Disconnected) => Some(HubIn::Stop),
|
||||
},
|
||||
None => Some(rx.recv().unwrap_or(HubIn::Stop)),
|
||||
};
|
||||
// A burst: handle what's waiting, then make it durable and answer.
|
||||
let mut next = first;
|
||||
let mut handled = 0;
|
||||
while let Some(m) = next.take() {
|
||||
if matches!(m, HubIn::Stop) {
|
||||
break 'run;
|
||||
}
|
||||
self.handle(m);
|
||||
handled += 1;
|
||||
if handled < BURST {
|
||||
next = rx.try_recv().ok();
|
||||
}
|
||||
}
|
||||
self.flush_due(Instant::now());
|
||||
self.send_out();
|
||||
}
|
||||
self.send_out();
|
||||
self.backend.stop(self.rooms.iter_mut().map(|(f, r)| (f.as_str(), &mut r.server)).collect());
|
||||
let (code, why) = match self.backend.closing() {
|
||||
Closing::Ended => (CloseCode::Away, ENDED),
|
||||
Closing::Restarting => (CloseCode::Restart, RESTARTING),
|
||||
};
|
||||
for c in self.conns.values() {
|
||||
if c.out.try_send(Out::Close(code, why.into())).is_err() {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, m: HubIn) {
|
||||
match m {
|
||||
HubIn::Open { conn, first: ClientMsg::Hello(hello), out, sock } => self.join(conn, hello, out, sock),
|
||||
HubIn::Open { conn, first: ClientMsg::Manage(m), out, sock } => self.manage(conn, &m, out, sock),
|
||||
HubIn::Open { out, .. } => refuse(&out, "say hello first"),
|
||||
HubIn::Msg { conn, msg } => match self.conns.get(&conn).map(|c| &c.kind) {
|
||||
Some(Kind::Joined { file, id, .. }) => {
|
||||
let (file, id) = (file.clone(), *id);
|
||||
self.client_message(&file, id, msg);
|
||||
}
|
||||
Some(Kind::Managing) => match msg {
|
||||
ClientMsg::Admin(req) => self.admin(conn, req),
|
||||
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
|
||||
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
|
||||
},
|
||||
None => {}
|
||||
},
|
||||
HubIn::Gone { conn } => self.gone(conn),
|
||||
HubIn::Local(p) | HubIn::LocalMsg(ClientMsg::Presence(p)) => {
|
||||
if let Some((file, id)) = self.local_id() {
|
||||
self.presence(&file, id, p);
|
||||
}
|
||||
}
|
||||
HubIn::LocalMsg(msg) => {
|
||||
if let Some((file, id)) = self.local_id() {
|
||||
self.client_message(&file, id, msg);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
HubIn::Cut => {
|
||||
for c in self.conns.values() {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
}
|
||||
HubIn::Stop => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hosting person's file and client id.
|
||||
fn local_id(&self) -> Option<(String, u32)> {
|
||||
let file = self.local.clone()?;
|
||||
let id = self.rooms.get(&file)?.local.as_ref()?.0;
|
||||
Some((file, id))
|
||||
}
|
||||
|
||||
/// A message from client `id` of `file`.
|
||||
fn client_message(&mut self, file: &str, id: u32, msg: ClientMsg) {
|
||||
if let ClientMsg::Presence(p) = msg {
|
||||
return self.presence(file, id, p);
|
||||
}
|
||||
let Some(room) = self.rooms.get_mut(file) else { return };
|
||||
let outs = room.server.receive(id, msg);
|
||||
self.backend.changed(file, &mut room.server);
|
||||
self.queue(file, outs);
|
||||
}
|
||||
|
||||
fn queue(&mut self, file: &str, outs: Vec<Outgoing>) {
|
||||
self.outbox.extend(outs.into_iter().map(|o| (file.to_string(), o)));
|
||||
}
|
||||
|
||||
fn join(&mut self, conn: u64, mut hello: Hello, out: SyncSender<Out>, sock: TcpStream) {
|
||||
let admission = match self.backend.admit(&hello.file, &hello.token) {
|
||||
Ok(a) => a,
|
||||
Err(why) => return refuse(&out, &why),
|
||||
};
|
||||
// The backend checked the secret: the sessions don't keep it.
|
||||
hello.token.clear();
|
||||
let file = hello.file.clone();
|
||||
if !self.rooms.contains_key(&file) {
|
||||
match self.backend.open(&file) {
|
||||
Ok(server) => {
|
||||
self.rooms.insert(file.clone(), Room::new(server));
|
||||
}
|
||||
Err(why) => return refuse(&out, &why),
|
||||
}
|
||||
}
|
||||
let Some(room) = self.rooms.get_mut(&file) else { return };
|
||||
// Colours: the first one nobody has.
|
||||
let taken: Vec<&str> = room.server.peers().map(|p| p.color.as_str()).collect();
|
||||
let color = PEOPLE.iter().find(|c| !taken.contains(c)).copied().unwrap_or(PEOPLE[taken.len() % PEOPLE.len()]);
|
||||
hello.color = color.to_string();
|
||||
hello.name = match clean_name(&hello.name) {
|
||||
n if n.is_empty() => {
|
||||
room.guests += 1;
|
||||
format!("Guest {}", room.guests)
|
||||
}
|
||||
n => n,
|
||||
};
|
||||
match room.server.join_as(hello, admission.access) {
|
||||
Ok((id, outs)) => {
|
||||
// A guest back after a drop keeps its id: its old connection, if the hub hasn't
|
||||
// noticed it's gone yet, is closed without the guest leaving.
|
||||
let old = room.ids.insert(id, conn).filter(|old| *old != conn);
|
||||
self.backend.changed(&file, &mut room.server);
|
||||
// The newcomer learns how it is shown, then where everyone is.
|
||||
let mut more = outs;
|
||||
if let Some(me) = room.server.peers().find(|p| p.client_id == id) {
|
||||
more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone())));
|
||||
}
|
||||
more.extend(room.latest.iter().map(|(&from, p)| Outgoing::To(id, ServerMsg::Presence { client_id: from, presence: p.clone() })));
|
||||
if let Some(c) = old.and_then(|old| self.conns.remove(&old)) {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
self.conns.insert(conn, Conn { out, sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link } });
|
||||
self.queue(&file, more);
|
||||
}
|
||||
Err(ServerMsg::Refused { reason }) => {
|
||||
refuse(&out, &reason);
|
||||
self.close_if_empty(&file);
|
||||
}
|
||||
Err(_) => {
|
||||
refuse(&out, "refused");
|
||||
self.close_if_empty(&file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manage(&mut self, conn: u64, m: &Manage, out: SyncSender<Out>, sock: TcpStream) {
|
||||
if m.protocol != PROTOCOL {
|
||||
return refuse(&out, &format!("protocol {} isn't supported (this server speaks {PROTOCOL})", m.protocol));
|
||||
}
|
||||
if let Err(why) = self.backend.manage(&m.key) {
|
||||
return refuse(&out, &why);
|
||||
}
|
||||
log::info!("live session: connection {conn} manages files");
|
||||
self.conns.insert(conn, Conn { out, sock, kind: Kind::Managing });
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
|
||||
}
|
||||
|
||||
fn admin(&mut self, conn: u64, req: Admin) {
|
||||
let rooms = &self.rooms;
|
||||
let open = |file: &str| {
|
||||
rooms.get(file).map(|r| OpenFile { people: u32::try_from(r.server.peers().count()).unwrap_or(u32::MAX), seq: r.server.seq() })
|
||||
};
|
||||
let handled = self.backend.admin(req, &open);
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
|
||||
if let Some((file, link)) = handled.revoked {
|
||||
let users: Vec<u64> = self
|
||||
.conns
|
||||
.iter()
|
||||
.filter(|(_, c)| matches!(&c.kind, Kind::Joined { file: f, link: Some(l), .. } if *f == file && *l == link))
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
self.kick(&users, "the link you joined with was revoked");
|
||||
}
|
||||
if let Some(file) = handled.removed {
|
||||
let users: Vec<u64> =
|
||||
self.conns.iter().filter(|(_, c)| matches!(&c.kind, Kind::Joined { file: f, .. } if *f == file)).map(|(k, _)| *k).collect();
|
||||
// Closed without saving: the file is gone.
|
||||
self.rooms.remove(&file);
|
||||
self.kick(&users, "this file was removed from the server");
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one message to one connection now (managing connections' answers).
|
||||
fn answer(&mut self, conn: u64, msg: &ServerMsg) {
|
||||
let Some(c) = self.conns.get(&conn) else { return };
|
||||
let sent = encode(msg).is_some_and(|b| c.out.try_send(Out::Bytes(b)).is_ok());
|
||||
if !sent {
|
||||
shutdown(&c.sock);
|
||||
self.gone(conn);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse connections with `why` and drop them at once (nothing more they send counts).
|
||||
fn kick(&mut self, conns: &[u64], why: &str) {
|
||||
for conn in conns {
|
||||
if let Some(c) = self.conns.get(conn) {
|
||||
refuse(&c.out, why);
|
||||
}
|
||||
self.gone(*conn);
|
||||
}
|
||||
}
|
||||
|
||||
fn gone(&mut self, conn: u64) {
|
||||
let Some(c) = self.conns.remove(&conn) else { return };
|
||||
let Kind::Joined { file, id, .. } = c.kind else { return };
|
||||
let Some(room) = self.rooms.get_mut(&file) else { return };
|
||||
if room.ids.get(&id) == Some(&conn) {
|
||||
room.ids.remove(&id);
|
||||
}
|
||||
room.throttles.remove(&id);
|
||||
room.latest.remove(&id);
|
||||
let outs = room.server.leave(id);
|
||||
self.queue(&file, outs);
|
||||
self.close_if_empty(&file);
|
||||
}
|
||||
|
||||
/// Nobody is in `file`: the backend saves it and may close it.
|
||||
fn close_if_empty(&mut self, file: &str) {
|
||||
let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return };
|
||||
if self.backend.idle(file, &mut room.server) {
|
||||
self.rooms.remove(file);
|
||||
}
|
||||
}
|
||||
|
||||
fn presence(&mut self, file: &str, id: u32, p: Presence) {
|
||||
let Some(room) = self.rooms.get_mut(file) else { return };
|
||||
let p = sanitize(p);
|
||||
room.latest.insert(id, p.clone());
|
||||
if let Some(p) = room.throttles.entry(id).or_insert_with(|| Throttle::new(PRESENCE_INTERVAL)).offer(p, Instant::now()) {
|
||||
let outs = room.server.receive(id, ClientMsg::Presence(p));
|
||||
self.queue(file, outs);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_due(&mut self, now: Instant) {
|
||||
let mut outs = Vec::new();
|
||||
for (file, room) in &mut self.rooms {
|
||||
let due: Vec<(u32, Presence)> = room.throttles.iter_mut().filter_map(|(id, t)| t.take_due(now).map(|p| (*id, p))).collect();
|
||||
for (id, p) in due {
|
||||
outs.extend(room.server.receive(id, ClientMsg::Presence(p)).into_iter().map(|o| (file.clone(), o)));
|
||||
}
|
||||
}
|
||||
self.outbox.extend(outs);
|
||||
}
|
||||
|
||||
/// Make what changed durable, then send what the servers said; connections that can't keep up
|
||||
/// are dropped (which may say more: that they left).
|
||||
fn send_out(&mut self) {
|
||||
self.backend.flush();
|
||||
while !self.outbox.is_empty() {
|
||||
let mut stuck = Vec::new();
|
||||
for (file, o) in std::mem::take(&mut self.outbox) {
|
||||
let Some(room) = self.rooms.get(&file) else { continue };
|
||||
let (to, msg): (Vec<u32>, ServerMsg) = match o {
|
||||
Outgoing::To(id, m) => (vec![id], m),
|
||||
Outgoing::All(m) => (room.server.peers().map(|p| p.client_id).collect(), m),
|
||||
Outgoing::Others(x, m) => (room.server.peers().map(|p| p.client_id).filter(|id| *id != x).collect(), m),
|
||||
};
|
||||
let mut bytes = None;
|
||||
for id in to {
|
||||
if let Some((local, inbox)) = room.local.as_ref().filter(|(l, _)| *l == id) {
|
||||
to_local(inbox, *local, &msg);
|
||||
continue;
|
||||
}
|
||||
let Some((&conn, c)) = room.ids.get(&id).and_then(|conn| self.conns.get_key_value(conn)) else { continue };
|
||||
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
|
||||
match c.out.try_send(Out::Bytes(b)) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_)) => {
|
||||
log::info!("live session: client {id} can't keep up; disconnecting it");
|
||||
stuck.push(conn);
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => stuck.push(conn),
|
||||
}
|
||||
}
|
||||
}
|
||||
for conn in stuck {
|
||||
if let Some(c) = self.conns.get(&conn) {
|
||||
shutdown(&c.sock);
|
||||
}
|
||||
self.gone(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the hosting person's window should know.
|
||||
fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) {
|
||||
match msg {
|
||||
ServerMsg::PeerJoined(p) if p.client_id != local => inbox.push(Event::PeerJoined(p.clone())),
|
||||
ServerMsg::PeerLeft { client_id } => inbox.push(Event::PeerLeft(*client_id)),
|
||||
ServerMsg::Presence { client_id, presence } => inbox.presence(*client_id, presence.clone()),
|
||||
ServerMsg::Changes { .. } | ServerMsg::Reject { .. } => inbox.push(Event::Sync(msg.clone())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +1,61 @@
|
|||
//! Invite links: `figcraft://join?host=<ip:port>&file=<id>&token=<secret>`, or the same session as a
|
||||
//! plain WebSocket URL `ws://<ip:port>/?file=<id>&token=<secret>`.
|
||||
//! Invite links and server addresses.
|
||||
//!
|
||||
//! An invite (or share) link names a session's endpoint, its file and a token:
|
||||
//! `figcraft://join?host=<host:port>&file=<id>&token=<secret>` for a plain `ws://` endpoint at `/`
|
||||
//! (what the desktop app hosts), `figcraft://join?server=<ws(s)://host[:port][/path]>&file=…&token=…`
|
||||
//! for any endpoint (a `figcraft-server` behind a TLS reverse proxy, under a path), or the same
|
||||
//! session as a WebSocket URL `ws(s)://host[:port][/path]?file=<id>&token=<secret>`.
|
||||
//!
|
||||
//! The token never travels in the WebSocket request (a proxy's logs would keep it): the client
|
||||
//! connects to `ws://<host>/` and sends the token in its `Hello`. Errors never echo the link.
|
||||
//! connects to the endpoint and sends the token in its `Hello`. A server address for managing
|
||||
//! files carries the server's key the same way (`ws://host:port/?key=<key>`). Errors never echo a
|
||||
//! link or an address.
|
||||
|
||||
/// Where a session or a server listens: `ws://host:port/path` or `wss://…` (TLS).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Endpoint {
|
||||
/// `host:port` (an IP address, `[v6]:port`, or a host name).
|
||||
pub host: String,
|
||||
/// The path it answers on: `/`, or a reverse proxy's prefix (`/figcraft/`).
|
||||
pub path: String,
|
||||
/// `wss://`: TLS (a reverse proxy in front of the server).
|
||||
pub secure: bool,
|
||||
}
|
||||
|
||||
/// What an invite link names.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Invite {
|
||||
/// `host:port` (an IP address, `[v6]:port`, or a host name).
|
||||
pub host: String,
|
||||
/// The shared file's session id.
|
||||
pub server: Endpoint,
|
||||
/// The shared file's id.
|
||||
pub file: String,
|
||||
/// The session's secret.
|
||||
/// The link's secret.
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Longest link accepted (anything longer isn't one of ours).
|
||||
const MAX_LINK: usize = 2048;
|
||||
|
||||
impl Invite {
|
||||
/// The `figcraft://join?…` link.
|
||||
pub fn link(&self) -> String {
|
||||
format!("figcraft://join?host={}&file={}&token={}", self.host, self.file, self.token)
|
||||
const NOT: &str = "that's not a live session invite: expected figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…";
|
||||
|
||||
impl Endpoint {
|
||||
/// A plain `ws://host/` endpoint.
|
||||
pub fn plain(host: impl Into<String>) -> Self {
|
||||
Self { host: host.into(), path: "/".into(), secure: false }
|
||||
}
|
||||
|
||||
/// The plain WebSocket URL of the same session.
|
||||
/// `ws://` at `/`: an invite can name it as `host=…`.
|
||||
fn is_plain(&self) -> bool {
|
||||
!self.secure && self.path == "/"
|
||||
}
|
||||
|
||||
/// The WebSocket URL to connect to (no secrets in it).
|
||||
pub fn url(&self) -> String {
|
||||
format!("ws://{}/?file={}&token={}", self.host, self.file, self.token)
|
||||
format!("{}://{}{}", if self.secure { "wss" } else { "ws" }, self.host, self.path)
|
||||
}
|
||||
|
||||
/// What the client connects to (no secrets in it).
|
||||
pub fn endpoint(&self) -> String {
|
||||
format!("ws://{}/", self.host)
|
||||
/// How the address is shown: `host:port` for a plain endpoint, else its URL.
|
||||
pub fn display(&self) -> String {
|
||||
if self.is_plain() { self.host.clone() } else { self.url() }
|
||||
}
|
||||
|
||||
/// The host is this machine (a loopback address or `localhost`).
|
||||
|
|
@ -42,49 +66,133 @@ impl Invite {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read an invite link (either form; surrounding spaces, quotes or angle brackets are fine).
|
||||
/// Read `ws(s)://host[:port][/path][?query]` (a missing port is the scheme's: 80 or 443): the
|
||||
/// endpoint and the query's decoded pairs. Errors say what's wrong without echoing `url`.
|
||||
pub fn parse(url: &str) -> Result<(Endpoint, Vec<(String, String)>), String> {
|
||||
const BAD: &str = "that's not a server address: expected ws://host:port/ or wss://host/";
|
||||
let url = clean(url);
|
||||
if url.len() > MAX_LINK {
|
||||
return Err(BAD.into());
|
||||
}
|
||||
let (scheme, rest) = url.split_once("://").ok_or(BAD)?;
|
||||
let secure = if scheme.eq_ignore_ascii_case("ws") {
|
||||
false
|
||||
} else if scheme.eq_ignore_ascii_case("wss") {
|
||||
true
|
||||
} else {
|
||||
return Err(BAD.into());
|
||||
};
|
||||
let (before_query, query) = rest.split_once('?').unwrap_or((rest, ""));
|
||||
let (authority, path) = match before_query.find('/') {
|
||||
Some(i) => before_query.split_at(i),
|
||||
None => (before_query, "/"),
|
||||
};
|
||||
let host = with_port(authority, if secure { 443 } else { 80 });
|
||||
if !valid_host(&host) {
|
||||
return Err("the address's host isn't a host name or IP address with a port (like 192.168.1.20:47900)".into());
|
||||
}
|
||||
if !valid_path(path) {
|
||||
return Err("the address's path has characters a server path can't have".into());
|
||||
}
|
||||
let mut path = path.to_string();
|
||||
if !path.ends_with('/') {
|
||||
path.push('/');
|
||||
}
|
||||
Ok((Endpoint { host, path, secure }, pairs(query)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Invite {
|
||||
/// An invite for a plain `ws://host/` endpoint.
|
||||
pub fn new(host: impl Into<String>, file: impl Into<String>, token: impl Into<String>) -> Self {
|
||||
Self { server: Endpoint::plain(host), file: file.into(), token: token.into() }
|
||||
}
|
||||
|
||||
/// The `figcraft://join?…` link.
|
||||
pub fn link(&self) -> String {
|
||||
if self.server.is_plain() {
|
||||
format!("figcraft://join?host={}&file={}&token={}", self.server.host, self.file, self.token)
|
||||
} else {
|
||||
format!("figcraft://join?server={}&file={}&token={}", self.server.url(), self.file, self.token)
|
||||
}
|
||||
}
|
||||
|
||||
/// The same session as a WebSocket URL.
|
||||
pub fn url(&self) -> String {
|
||||
format!("{}?file={}&token={}", self.server.url(), self.file, self.token)
|
||||
}
|
||||
|
||||
/// What the client connects to (no secrets in it).
|
||||
pub fn endpoint(&self) -> String {
|
||||
self.server.url()
|
||||
}
|
||||
|
||||
/// The host is this machine.
|
||||
pub fn loopback(&self) -> bool {
|
||||
self.server.loopback()
|
||||
}
|
||||
|
||||
/// Read an invite link (any form; surrounding spaces, quotes or angle brackets are fine).
|
||||
pub fn parse(link: &str) -> Result<Invite, String> {
|
||||
const NOT: &str = "that's not a live session invite: expected figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…";
|
||||
let link = link.trim().trim_matches(|c| matches!(c, '<' | '>' | '"' | '\''));
|
||||
let link = clean(link);
|
||||
if link.len() > MAX_LINK {
|
||||
return Err(NOT.into());
|
||||
}
|
||||
let (scheme, rest) = link.split_once("://").ok_or(NOT)?;
|
||||
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
|
||||
let mut host = None;
|
||||
let mut file = None;
|
||||
let mut token = None;
|
||||
for pair in query.split('&').filter(|p| !p.is_empty()) {
|
||||
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
let v = percent_decode(v).ok_or(NOT)?;
|
||||
match k {
|
||||
"host" => host = Some(v),
|
||||
"file" => file = Some(v),
|
||||
"token" => token = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if scheme.eq_ignore_ascii_case("figcraft") {
|
||||
let (server, query) = if scheme.eq_ignore_ascii_case("figcraft") {
|
||||
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
|
||||
if !path.trim_end_matches('/').eq_ignore_ascii_case("join") {
|
||||
return Err(NOT.into());
|
||||
}
|
||||
} else if scheme.eq_ignore_ascii_case("ws") {
|
||||
// ws://host:port/… : the host is the authority.
|
||||
host = Some(path.split('/').next().unwrap_or("").to_string());
|
||||
} else if scheme.eq_ignore_ascii_case("wss") {
|
||||
return Err("secure (wss://) live sessions need a server behind TLS, which comes with the standalone server".into());
|
||||
let query = pairs(query).map_err(|_| NOT)?;
|
||||
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
|
||||
let server = match (get("server"), get("host")) {
|
||||
(Some(url), _) => Endpoint::parse(&url).map(|(e, _)| e).map_err(|_| "the invite link's server isn't a ws:// or wss:// address")?,
|
||||
(None, Some(host)) if valid_host(&host) => Endpoint::plain(host),
|
||||
_ => return Err("the invite link's host isn't an address with a port (like 192.168.1.20:47900)".into()),
|
||||
};
|
||||
(server, query)
|
||||
} else if scheme.eq_ignore_ascii_case("ws") || scheme.eq_ignore_ascii_case("wss") {
|
||||
Endpoint::parse(link).map_err(|e| if e.contains("host") { e } else { NOT.into() })?
|
||||
} else {
|
||||
return Err(NOT.into());
|
||||
}
|
||||
let host = host.filter(|h| valid_host(h)).ok_or("the invite link's host isn't an address with a port (like 192.168.1.20:47900)")?;
|
||||
let file = file.filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
|
||||
let token = token.filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
|
||||
Ok(Invite { host, file, token })
|
||||
};
|
||||
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
|
||||
let file = get("file").filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
|
||||
let token = get("token").filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
|
||||
Ok(Invite { server, file, token })
|
||||
}
|
||||
}
|
||||
|
||||
/// Without surrounding spaces, quotes or angle brackets.
|
||||
fn clean(s: &str) -> &str {
|
||||
s.trim().trim_matches(|c| matches!(c, '<' | '>' | '"' | '\''))
|
||||
}
|
||||
|
||||
/// A query's `key=value` pairs, decoded.
|
||||
fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
|
||||
query
|
||||
.split('&')
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|pair| {
|
||||
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
Ok((k.to_string(), percent_decode(v).ok_or("a broken %-escape")?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `authority` with `default` as its port when it has none.
|
||||
fn with_port(authority: &str, default: u16) -> String {
|
||||
let has_port = match authority.rsplit_once(':') {
|
||||
// `[v6]` alone has colons inside the brackets only.
|
||||
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
|
||||
None => false,
|
||||
};
|
||||
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
|
||||
}
|
||||
|
||||
/// Ids and tokens are URL-safe base64 / hex characters.
|
||||
fn valid_id(s: &str, min: usize, max: usize) -> bool {
|
||||
pub(crate) fn valid_id(s: &str, min: usize, max: usize) -> bool {
|
||||
(min..=max).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +208,11 @@ fn valid_host(s: &str) -> bool {
|
|||
&& !name.starts_with(['-', '.'])
|
||||
}
|
||||
|
||||
/// A path of unreserved characters and slashes, starting with `/`.
|
||||
fn valid_path(p: &str) -> bool {
|
||||
p.starts_with('/') && p.len() <= 256 && p.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.' | b'~'))
|
||||
}
|
||||
|
||||
/// `%XX` escapes decoded (UTF-8); `None` for broken escapes.
|
||||
fn percent_decode(s: &str) -> Option<String> {
|
||||
let bytes = s.as_bytes();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
//! Invite links: both forms read back, bad links are refused without echoing them, and the
|
||||
//! parser survives anything.
|
||||
//! Invite links and server addresses: every form reads back, bad links are refused without
|
||||
//! echoing them, and the parsers survive anything.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use crate::invite::Invite;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
|
||||
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
fn invite() -> Invite {
|
||||
Invite { host: "192.168.1.20:47900".into(), file: "0123456789abcdef".into(), token: TOKEN.into() }
|
||||
Invite::new("192.168.1.20:47900", "0123456789abcdef", TOKEN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -23,17 +23,63 @@ fn both_forms_read_back() {
|
|||
assert_eq!(Invite::parse(&format!("figcraft://join?host=192.168.1.20%3A47900&file=0123456789abcdef&token={TOKEN}")), Ok(i.clone()));
|
||||
// The secret never travels in the WebSocket request.
|
||||
assert_eq!(i.endpoint(), "ws://192.168.1.20:47900/");
|
||||
assert_eq!(i.server.display(), "192.168.1.20:47900");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_can_be_v6_or_names() {
|
||||
let v6 = Invite { host: "[::1]:47900".into(), ..invite() };
|
||||
let v6 = Invite::new("[::1]:47900", "0123456789abcdef", TOKEN);
|
||||
assert_eq!(Invite::parse(&v6.link()), Ok(v6.clone()));
|
||||
assert_eq!(Invite::parse(&v6.url()), Ok(v6));
|
||||
let name = Invite { host: "studio.local:47900".into(), ..invite() };
|
||||
assert_eq!(Invite::parse(&v6.url()), Ok(v6.clone()));
|
||||
assert!(v6.loopback());
|
||||
let name = Invite::new("studio.local:47900", "0123456789abcdef", TOKEN);
|
||||
assert_eq!(Invite::parse(&name.link()), Ok(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_links_carry_tls_and_a_path() {
|
||||
// A server behind a TLS reverse proxy, under a path: the server= form.
|
||||
let i = Invite { server: Endpoint { host: "figcraft.example.com:443".into(), path: "/live/".into(), secure: true }, ..invite() };
|
||||
assert_eq!(i.link(), format!("figcraft://join?server=wss://figcraft.example.com:443/live/&file=0123456789abcdef&token={TOKEN}"));
|
||||
assert_eq!(i.url(), format!("wss://figcraft.example.com:443/live/?file=0123456789abcdef&token={TOKEN}"));
|
||||
assert_eq!(i.endpoint(), "wss://figcraft.example.com:443/live/");
|
||||
assert_eq!(i.server.display(), "wss://figcraft.example.com:443/live/");
|
||||
assert_eq!(Invite::parse(&i.link()), Ok(i.clone()));
|
||||
assert_eq!(Invite::parse(&i.url()), Ok(i.clone()));
|
||||
// The scheme's port when there's none, and a path without its last slash.
|
||||
assert_eq!(Invite::parse(&format!("wss://figcraft.example.com/live?file=0123456789abcdef&token={TOKEN}")), Ok(i.clone()));
|
||||
assert_eq!(
|
||||
Invite::parse(&format!("figcraft://join?server=wss%3A%2F%2Ffigcraft.example.com%2Flive%2F&file=0123456789abcdef&token={TOKEN}")),
|
||||
Ok(i)
|
||||
);
|
||||
// A plain server at / is the same as an app's invite.
|
||||
let plain = Invite::parse(&format!("figcraft://join?server=ws://10.0.0.5:47900&file=0123456789abcdef&token={TOKEN}")).unwrap();
|
||||
assert_eq!(plain, Invite::new("10.0.0.5:47900", "0123456789abcdef", TOKEN));
|
||||
assert!(plain.link().starts_with("figcraft://join?host=10.0.0.5:47900&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_addresses_with_a_key() {
|
||||
let (e, q) = Endpoint::parse("ws://127.0.0.1:47900/?key=KeyKeyKeyKeyKeyKeyKey0").unwrap();
|
||||
assert_eq!(e, Endpoint::plain("127.0.0.1:47900"));
|
||||
assert_eq!(q, vec![("key".to_string(), "KeyKeyKeyKeyKeyKeyKey0".to_string())]);
|
||||
let (e, q) = Endpoint::parse(" wss://example.org ").unwrap();
|
||||
assert_eq!(e, Endpoint { host: "example.org:443".into(), path: "/".into(), secure: true });
|
||||
assert!(q.is_empty());
|
||||
let secret = "SuperSecretToken123456";
|
||||
for bad in [
|
||||
format!("http://example.org/?key={secret}"),
|
||||
format!("ws://exa mple.org/?key={secret}"),
|
||||
format!("ws://example.org:0/?key={secret}"),
|
||||
format!("ws://example.org/a/?key={secret}%zz"),
|
||||
format!("ws://example.org/<x>/?key={secret}"),
|
||||
format!("example.org?key={secret}"),
|
||||
] {
|
||||
let e = Endpoint::parse(&bad).expect_err(&bad);
|
||||
assert!(!e.contains(secret), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_links_are_refused_without_echoing_them() {
|
||||
let secret = "SuperSecretToken123456";
|
||||
|
|
@ -49,7 +95,8 @@ fn bad_links_are_refused_without_echoing_them() {
|
|||
"figcraft://join?host=10.0.0.1:47900&file=abc&token=short".into(),
|
||||
format!("figcraft://join?host=10.0.0.1:47900&file=a/b&token={secret}"),
|
||||
format!("figcraft://join?host=10.0.0.1:47900&file=abc&token={secret}%zz"),
|
||||
format!("wss://10.0.0.1:47900/?file=abc&token={secret}"),
|
||||
format!("figcraft://join?server=http://10.0.0.1:47900/&file=abc&token={secret}"),
|
||||
format!("wss://10.0.0.1:47900/?file=abc&token=short{secret}!"),
|
||||
format!("figcraft://join?host=10.0.0.1:47900&file=abc&token={}", "x".repeat(3000)),
|
||||
] {
|
||||
let e = Invite::parse(&bad).expect_err(&bad);
|
||||
|
|
@ -59,15 +106,19 @@ fn bad_links_are_refused_without_echoing_them() {
|
|||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn the_parser_survives_anything(s in ".{0,300}") {
|
||||
fn the_parsers_survive_anything(s in ".{0,300}") {
|
||||
let _ = Invite::parse(&s);
|
||||
let _ = Invite::parse(&format!("figcraft://join?{s}"));
|
||||
let _ = Invite::parse(&format!("figcraft://join?server={s}"));
|
||||
let _ = Invite::parse(&format!("ws://{s}"));
|
||||
let _ = Invite::parse(&format!("wss://{s}"));
|
||||
let _ = Endpoint::parse(&s);
|
||||
let _ = Endpoint::parse(&format!("ws://{s}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_invites_round_trip(a in 1u8..255, port in 1u16.., file in "[a-f0-9]{1,64}", token in "[A-Za-z0-9_-]{16,128}") {
|
||||
let i = Invite { host: format!("10.0.0.{a}:{port}"), file, token };
|
||||
fn valid_invites_round_trip(a in 1u8..255, port in 1u16.., file in "[a-f0-9]{1,64}", token in "[A-Za-z0-9_-]{16,128}", path in "(/[a-z0-9]{1,8}){0,3}", secure: bool) {
|
||||
let i = Invite { server: Endpoint { host: format!("10.0.0.{a}:{port}"), path: format!("{path}/"), secure }, file, token };
|
||||
prop_assert_eq!(Invite::parse(&i.link()), Ok(i.clone()));
|
||||
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,14 @@ use crate::limits::NetLimits;
|
|||
/// What the host tells everyone when it stops (the WebSocket close reason).
|
||||
pub const ENDED: &str = "The host ended the session";
|
||||
|
||||
/// WebSocket close codes the host uses: it ended the session (going away), or it refused you
|
||||
/// (policy).
|
||||
/// What a server tells everyone when it stops (they reconnect once it is back).
|
||||
pub const RESTARTING: &str = "The server is restarting";
|
||||
|
||||
/// WebSocket close codes hosts use: the app's session ended (going away), you were refused
|
||||
/// (policy), or the server is restarting (service restart: try again).
|
||||
pub(crate) const CLOSE_AWAY: u16 = 1001;
|
||||
pub(crate) const CLOSE_POLICY: u16 = 1008;
|
||||
pub(crate) const CLOSE_RESTART: u16 = 1012;
|
||||
|
||||
/// How a guest connects and reconnects.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -72,6 +76,7 @@ pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
|
|||
match code {
|
||||
CLOSE_AWAY => Outcome::Ended(if reason.is_empty() { ENDED.to_string() } else { reason.to_string() }),
|
||||
CLOSE_POLICY => Outcome::Refused(reason.to_string()),
|
||||
CLOSE_RESTART => Outcome::Retry(if reason.is_empty() { RESTARTING.to_string() } else { reason.to_string() }),
|
||||
_ => Outcome::Retry("the host closed the connection".into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -143,7 +148,7 @@ pub(crate) enum Taken {
|
|||
/// in answer must go out on this connection.
|
||||
pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
|
||||
match m {
|
||||
ServerMsg::Welcome { session, client_id, seq, ref peers, .. } => {
|
||||
ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => {
|
||||
if let Ok(mut r) = resume.lock() {
|
||||
*r = Some(Resume { session, client_id, seq });
|
||||
}
|
||||
|
|
@ -159,8 +164,9 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
|
|||
pages: Vec::new(),
|
||||
link: inv.link(),
|
||||
url: inv.url(),
|
||||
address: inv.host.clone(),
|
||||
address: inv.server.display(),
|
||||
network: !inv.loopback(),
|
||||
access,
|
||||
};
|
||||
seen.peers = peers.clone();
|
||||
seen.info = Some(info.clone());
|
||||
|
|
@ -201,7 +207,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
|
|||
inbox.push(Event::Sync(m));
|
||||
}
|
||||
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
|
||||
ServerMsg::Pong => {}
|
||||
// Answers for managing connections: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) => {}
|
||||
}
|
||||
Taken::Continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,14 @@ pub mod guest;
|
|||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod host;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod hub;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod link;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod links;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod manage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod net;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod token;
|
||||
|
|
@ -38,7 +42,7 @@ pub mod token;
|
|||
mod web;
|
||||
|
||||
pub use inbox::{Inbox, Waker};
|
||||
pub use invite::Invite;
|
||||
pub use invite::{Endpoint, Invite};
|
||||
pub use limits::NetLimits;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use link::{DEFAULT_PORT, NetLink, lan_ip};
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||
|
||||
use figcraft_collab::message::ClientMsg;
|
||||
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, Role, SessionInfo};
|
||||
use figcraft_collab::message::{Access, ClientMsg};
|
||||
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Role, SessionInfo};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig};
|
||||
use crate::guest::{Guest, GuestConfig, Publish};
|
||||
use crate::host::{Host, HostConfig};
|
||||
use crate::inbox::{Inbox, Waker};
|
||||
use crate::invite::Invite;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::manage::NO_TLS;
|
||||
use crate::net::NetLimits;
|
||||
use crate::token;
|
||||
|
||||
|
|
@ -48,6 +49,13 @@ impl NetLink {
|
|||
self
|
||||
}
|
||||
|
||||
/// How this link joins `invite`.
|
||||
fn guest_config(&self, invite: Invite, name: &str) -> GuestConfig {
|
||||
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
|
||||
cfg.limits = self.limits;
|
||||
cfg
|
||||
}
|
||||
|
||||
/// The running host, if hosting.
|
||||
pub fn host_handle(&self) -> Option<&Host> {
|
||||
match &self.active {
|
||||
|
|
@ -98,7 +106,7 @@ impl LiveLink for NetLink {
|
|||
core: figcraft_collab::Limits::default(),
|
||||
};
|
||||
let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?;
|
||||
let invite = Invite { host: host.addr().to_string(), file, token };
|
||||
let invite = Invite::new(host.addr().to_string(), file, token);
|
||||
let info = SessionInfo {
|
||||
role: Role::Host,
|
||||
me: host.me().client_id,
|
||||
|
|
@ -110,6 +118,7 @@ impl LiveLink for NetLink {
|
|||
url: invite.url(),
|
||||
address: host.addr().to_string(),
|
||||
network: !ip.is_loopback(),
|
||||
access: Access::Edit,
|
||||
};
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(Active::Host(host));
|
||||
|
|
@ -118,16 +127,36 @@ 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());
|
||||
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
|
||||
cfg.limits = self.limits;
|
||||
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(Active::Guest(guest));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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.
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
|
||||
self.leave();
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
let job = Publish { server, key, name: o.name, doc: o.doc };
|
||||
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(Active::Guest(guest));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leave(&mut self) {
|
||||
match self.active.take() {
|
||||
Some(Active::Host(mut h)) => h.stop(),
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
|
|||
core,
|
||||
};
|
||||
let host = Host::start(cfg, probe.inbox.clone()).unwrap();
|
||||
let invite = Invite { host: host.addr().to_string(), file: FILE.into(), token: TOKEN.into() };
|
||||
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
|
||||
(host, probe, invite)
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +240,7 @@ fn a_wrong_token_or_file_is_refused() {
|
|||
fn unreachable_hosts_fail_at_once() {
|
||||
// A port nothing listens on.
|
||||
let free = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
|
||||
let (_g, mut gp) = join(&Invite { host: free.to_string(), file: FILE.into(), token: TOKEN.into() }, "Ada");
|
||||
let (_g, mut gp) = join(&Invite::new(free.to_string(), FILE, TOKEN), "Ada");
|
||||
gp.wait("a failure", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.starts_with("Couldn't join"))));
|
||||
assert!(!gp.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a first join doesn't retry");
|
||||
}
|
||||
|
|
@ -292,7 +292,7 @@ fn a_newcomer_sees_where_everyone_already_is() {
|
|||
#[test]
|
||||
fn presence_is_throttled_and_the_latest_wins() {
|
||||
let (mut host, _hp, invite) = start();
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut watcher = Raw::hello(addr, TOKEN);
|
||||
assert!(matches!(watcher.next(), Some(ServerMsg::Welcome { .. })));
|
||||
let mut sender = Raw::hello(addr, TOKEN);
|
||||
|
|
@ -336,7 +336,7 @@ fn leaving_and_dropping_clean_up() {
|
|||
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
|
||||
connections_settle(&host, 1);
|
||||
// A connection that just vanishes (no close) counts as leaving too.
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut raw = Raw::hello(addr, TOKEN);
|
||||
let Some(ServerMsg::Welcome { client_id: rid, .. }) = raw.next() else { panic!("no welcome") };
|
||||
drop(raw);
|
||||
|
|
@ -366,7 +366,7 @@ fn a_full_session_refuses_more() {
|
|||
#[test]
|
||||
fn too_many_connections_are_turned_away() {
|
||||
let (mut host, _hp, invite) = start_with(NetLimits { max_connections: 2, ..fast_limits() }, Limits::default());
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut a = Raw::hello(addr, TOKEN);
|
||||
let mut b = Raw::hello(addr, TOKEN);
|
||||
assert!(a.next().is_some() && b.next().is_some());
|
||||
|
|
@ -380,7 +380,7 @@ fn too_many_connections_are_turned_away() {
|
|||
#[test]
|
||||
fn oversized_messages_end_the_connection() {
|
||||
let (mut host, mut hp, invite) = start_with(NetLimits { max_client_message: 4096, ..fast_limits() }, Limits::default());
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut raw = Raw::hello(addr, TOKEN);
|
||||
let Some(ServerMsg::Welcome { client_id, .. }) = raw.next() else { panic!("no welcome") };
|
||||
raw.send(&ClientMsg::Presence(Presence { selection: Some((0..5000).map(NodeId).collect()), ..Presence::default() }));
|
||||
|
|
@ -395,7 +395,7 @@ fn oversized_messages_end_the_connection() {
|
|||
#[test]
|
||||
fn flooding_ends_the_connection() {
|
||||
let (mut host, _hp, invite) = start_with(NetLimits { rate: 5.0, burst: 20.0, ..fast_limits() }, Limits::default());
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut raw = Raw::hello(addr, TOKEN);
|
||||
assert!(matches!(raw.next(), Some(ServerMsg::Welcome { .. })));
|
||||
for _ in 0..100 {
|
||||
|
|
@ -410,7 +410,7 @@ fn flooding_ends_the_connection() {
|
|||
#[test]
|
||||
fn malformed_frames_are_refused() {
|
||||
let (mut host, _hp, invite) = start();
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
// Garbage instead of a hello, a wrong wire version, text, and something other than a hello.
|
||||
type Case = (Box<dyn Fn(&mut Raw)>, &'static str);
|
||||
let cases: Vec<Case> = vec![
|
||||
|
|
@ -445,7 +445,7 @@ fn random_frames_never_hurt_the_host() {
|
|||
use proptest::strategy::{Strategy, ValueTree};
|
||||
use proptest::test_runner::TestRunner;
|
||||
let (mut host, _hp, invite) = start();
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut runner = TestRunner::deterministic();
|
||||
let mut join_first = false;
|
||||
let frames = proptest::collection::vec(proptest::collection::vec(proptest::num::u8::ANY, 0..200), 1..6);
|
||||
|
|
@ -476,7 +476,7 @@ fn random_frames_never_hurt_the_host() {
|
|||
#[test]
|
||||
fn silent_connections_time_out() {
|
||||
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_millis(200), ..fast_limits() }, Limits::default());
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(WAIT)).unwrap();
|
||||
let t0 = Instant::now();
|
||||
|
|
@ -545,7 +545,7 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
|
|||
let mut gp = probe;
|
||||
gp.wait("welcome", welcomed);
|
||||
// A joined raw client that never says anything again.
|
||||
let addr: SocketAddr = invite.host.parse().unwrap();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut raw = Raw::hello(addr, TOKEN);
|
||||
let Some(ServerMsg::Welcome { client_id: silent, .. }) = raw.next() else { panic!("no welcome") };
|
||||
hp.wait("the silent one is dropped", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == silent)));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
//! Managing a server's files from a client: one connection that opens with the server's key
|
||||
//! ([`Manage`]) and sends requests, each answered in order. The app publishes through it, and the
|
||||
//! server's command line uses it to reach a running server.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
use crate::invite::Endpoint;
|
||||
use crate::net::{NetLimits, config, dial};
|
||||
|
||||
/// 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";
|
||||
|
||||
/// 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
|
||||
/// [`AdminReply::Failed`] reply, not an error). Never echoes the key.
|
||||
pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetLimits, timeout: Duration) -> Result<Vec<AdminReply>, String> {
|
||||
if server.secure {
|
||||
return Err(NO_TLS.into());
|
||||
}
|
||||
let stream = dial(&server.host, timeout, limits)?;
|
||||
let (mut 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()))?;
|
||||
// A large upload takes the server a while to keep.
|
||||
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
|
||||
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
|
||||
send(&mut ws, &ClientMsg::Manage(manage))?;
|
||||
match reply(&mut ws, limits)? {
|
||||
AdminReply::Ready => {}
|
||||
other => return Err(format!("the server answered {other:?} instead of letting this connection manage files")),
|
||||
}
|
||||
let mut replies = Vec::with_capacity(requests.len());
|
||||
for r in requests {
|
||||
send(&mut ws, &ClientMsg::Admin(r))?;
|
||||
replies.push(reply(&mut ws, limits)?);
|
||||
}
|
||||
let _ = ws.close(None);
|
||||
// Read until the server's close arrives (or the socket ends), so the close isn't lost.
|
||||
while ws.read().is_ok() {}
|
||||
Ok(replies)
|
||||
}
|
||||
|
||||
fn send(ws: &mut WebSocket<std::net::TcpStream>, msg: &ClientMsg) -> Result<(), String> {
|
||||
let bytes = message::encode(msg).map_err(|e| e.to_string())?;
|
||||
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
|
||||
}
|
||||
|
||||
/// The next answer: an admin reply, or why there is none.
|
||||
fn reply(ws: &mut WebSocket<std::net::TcpStream>, limits: &NetLimits) -> Result<AdminReply, String> {
|
||||
loop {
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
|
||||
Ok(ServerMsg::Admin(r)) => Ok(*r),
|
||||
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
|
||||
Ok(ServerMsg::Pong) => continue,
|
||||
Ok(_) => Err("the server sent something other than an answer".into()),
|
||||
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
|
||||
},
|
||||
Ok(Message::Close(f)) => Err(match f.map(|f| f.reason.to_string()).filter(|r| !r.is_empty()) {
|
||||
Some(r) => format!("the server closed the connection: {r}"),
|
||||
None => "the server closed the connection".into(),
|
||||
}),
|
||||
Ok(Message::Text(_)) => Err("the server sent a text message".into()),
|
||||
Ok(_) => continue,
|
||||
Err(e) => Err(format!("no answer from the server ({e})")),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,26 @@ pub fn shutdown(s: &TcpStream) {
|
|||
let _ = s.shutdown(Shutdown::Both);
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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}");
|
||||
for a in addrs {
|
||||
match TcpStream::connect_timeout(&a, timeout) {
|
||||
Ok(s) => {
|
||||
let _ = s.set_nodelay(true);
|
||||
s.set_read_timeout(Some(limits.hello_timeout)).map_err(|e| e.to_string())?;
|
||||
s.set_write_timeout(Some(limits.write_timeout)).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
}
|
||||
Err(e) => last = format!("no answer from {a}: {e}"),
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// A token bucket: `rate` per second on average, `burst` at once.
|
||||
#[derive(Debug)]
|
||||
pub struct Bucket {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! The browser client (step 4 of `docs/collab-protocol.md`): the web build joins a session a
|
||||
//! desktop app hosts, through the browser's WebSocket API (`web-sys`), with the same messages as
|
||||
//! the native guest. The connection logic is the [`Driver`]'s; this file feeds it the socket's
|
||||
//! desktop app hosts, or a file on a `figcraft-server` (also over `wss://` behind a TLS reverse
|
||||
//! proxy), through the browser's WebSocket API (`web-sys`), with the same messages as the native
|
||||
//! guest. The connection logic is the [`Driver`]'s; this file feeds it the socket's
|
||||
//! events and wakes it with a timer. Hosting needs the desktop app (a page can't listen on a
|
||||
//! socket).
|
||||
//!
|
||||
|
|
@ -8,8 +9,8 @@
|
|||
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only the session's
|
||||
//! number and its [`Inbox`]. Whatever arrives wakes egui through the [`Waker`]; nothing polls.
|
||||
//!
|
||||
//! An `https://` page can't open the unencrypted `ws://` connection of a desktop-hosted session
|
||||
//! (browsers block mixed content) except to this machine; [`NetLink::join`] says so up front.
|
||||
//! An `https://` page can't open an unencrypted `ws://` connection (browsers block mixed content)
|
||||
//! except to this machine; [`NetLink::join`] says so up front.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
|
|
@ -167,8 +168,8 @@ impl LiveLink for NetLink {
|
|||
|
||||
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
|
||||
let invite = Invite::parse(link)?;
|
||||
if secure_page() && !invite.loopback() {
|
||||
return Err("this page was loaded over https, and browsers block the unencrypted ws:// connection of a session the desktop app hosts; open FigCraft from an http:// address (like http://localhost:8767) or join from the desktop app".into());
|
||||
if secure_page() && !invite.server.secure && !invite.loopback() {
|
||||
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// link (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or join from the desktop app".into());
|
||||
}
|
||||
self.leave();
|
||||
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use figcraft_doc::{Document, NodeId};
|
|||
|
||||
use crate::change::{Change, Prop};
|
||||
use crate::history::{BACK, BatchKind, FWD, Notice, Step, Touches};
|
||||
use crate::message::{Accepted, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg};
|
||||
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg};
|
||||
use crate::replica::{Mode, Replica};
|
||||
use crate::{Diff, Error, NoRecompute, Recompute, diff};
|
||||
|
||||
|
|
@ -59,6 +59,8 @@ pub struct Client {
|
|||
next_step: u64,
|
||||
history_limit: usize,
|
||||
notices: Vec<Notice>,
|
||||
/// What the server lets this client do (a viewer's edits are refused).
|
||||
access: Access,
|
||||
}
|
||||
|
||||
/// The step with id `id` in either stack.
|
||||
|
|
@ -82,7 +84,7 @@ impl Client {
|
|||
|
||||
/// Start from the server's `Welcome` (which must carry a snapshot).
|
||||
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), peers, .. } = welcome else {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), peers, access, .. } = welcome else {
|
||||
return Err(Error::Protocol("expected a welcome with the document".into()));
|
||||
};
|
||||
let mut confirmed = Replica::from_snapshot(&snapshot)?;
|
||||
|
|
@ -105,6 +107,7 @@ impl Client {
|
|||
next_step: 1,
|
||||
history_limit: HISTORY_LIMIT,
|
||||
notices: Vec::new(),
|
||||
access,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +116,11 @@ impl Client {
|
|||
self.id
|
||||
}
|
||||
|
||||
/// What the server lets this client do (from the latest welcome).
|
||||
pub fn access(&self) -> Access {
|
||||
self.access
|
||||
}
|
||||
|
||||
/// The document as this user sees it (server state plus their unconfirmed edits). Edit a copy
|
||||
/// (it allocates ids in this client's space) and hand it to [`edit`](Self::edit).
|
||||
pub fn document(&self) -> &Document {
|
||||
|
|
@ -443,13 +451,15 @@ impl Client {
|
|||
}
|
||||
ServerMsg::Pong => Ok(Vec::new()),
|
||||
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
|
||||
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers } => {
|
||||
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
|
||||
if session != self.session || client_id != self.id {
|
||||
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers });
|
||||
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access });
|
||||
}
|
||||
self.access = access;
|
||||
self.rejoin(seq, catch_up, &processed, peers, recompute)
|
||||
}
|
||||
ServerMsg::Refused { reason } => Err(Error::Protocol(reason)),
|
||||
ServerMsg::Admin(_) => Err(Error::Protocol("an answer meant for a managing connection".into())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ use figcraft_doc::{Document, NodeId, NodeKind};
|
|||
|
||||
use crate::change::{Change, Json, Prop};
|
||||
use crate::message::{
|
||||
Accepted, CatchUp, ClientMsg, EditingText, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, WIRE_VERSION, decode, decode_json,
|
||||
encode, encode_json,
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, Hello, LinkInfo, Manage, NewLink, PROTOCOL, Peer, Presence,
|
||||
Processed, Resume, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
};
|
||||
use crate::sim::Rng;
|
||||
use crate::{Error, Replica, diff};
|
||||
|
|
@ -49,8 +49,18 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
}),
|
||||
ClientMsg::Ack { seq: 9 },
|
||||
ClientMsg::Ping,
|
||||
ClientMsg::Manage(Manage { protocol: PROTOCOL, key: "k".into(), client_version: "0.3.1".into() }),
|
||||
ClientMsg::Admin(Admin::Publish { name: "Café".into(), snapshot: Replica::new(Document::new("Up")).snapshot().unwrap() }),
|
||||
ClientMsg::Admin(Admin::Files),
|
||||
ClientMsg::Admin(Admin::Links { file: "f".into() }),
|
||||
ClientMsg::Admin(Admin::CreateLink { file: "f".into(), access: Access::View, label: "Review".into() }),
|
||||
ClientMsg::Admin(Admin::Revoke { file: "f".into(), link: "l".into() }),
|
||||
ClientMsg::Admin(Admin::Remove { file: "f".into() }),
|
||||
];
|
||||
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
|
||||
let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 };
|
||||
let link = LinkInfo { id: "l".into(), file: "f".into(), access: Access::Edit, label: String::new(), created: 6 };
|
||||
let new_link = NewLink { link: link.clone(), token: "t".into() };
|
||||
let server = vec![
|
||||
ServerMsg::Welcome {
|
||||
session: 77,
|
||||
|
|
@ -59,6 +69,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
catch_up: CatchUp::Snapshot(snapshot),
|
||||
processed: Processed::default(),
|
||||
peers: vec![peer.clone()],
|
||||
access: Access::Edit,
|
||||
},
|
||||
ServerMsg::Welcome {
|
||||
session: 77,
|
||||
|
|
@ -67,6 +78,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
catch_up: CatchUp::Delta(vec![Accepted { seq: 11, author: 5, batch_id: 1, changes: vec![set.clone()] }]),
|
||||
processed: Processed { last_batch: 7, refused: vec![3, 6] },
|
||||
peers: vec![],
|
||||
access: Access::View,
|
||||
},
|
||||
ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }),
|
||||
ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] },
|
||||
|
|
@ -75,6 +87,13 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
ServerMsg::PeerLeft { client_id: 4 },
|
||||
ServerMsg::Refused { reason: "full".into() },
|
||||
ServerMsg::Pong,
|
||||
ServerMsg::Admin(Box::new(AdminReply::Ready)),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Published { file: file.clone(), edit: new_link.clone(), view: new_link.clone() })),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Links(vec![link]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Link(new_link))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Done)),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })),
|
||||
];
|
||||
(client, server)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
//! View-only participants and the journal a store keeps between snapshots: a viewer's batches are
|
||||
//! refused with the reason and rolled back, everything else reaches it; replaying the journal over
|
||||
//! the last saved state gives the same server (clients resume into it), entries already in the
|
||||
//! state change nothing, and a log with a gap is refused.
|
||||
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
|
||||
use crate::message::{self, Access, ClientMsg, Hello, ServerMsg};
|
||||
use crate::sim_tests::base;
|
||||
use crate::{Client, Journal, Limits, NoRecompute, Outgoing, Server, VIEW_ONLY};
|
||||
|
||||
fn hello() -> Hello {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") };
|
||||
h
|
||||
}
|
||||
|
||||
fn join_as(s: &mut Server, access: Access) -> Client {
|
||||
let (_, out) = s.join_as(hello(), access).unwrap();
|
||||
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") };
|
||||
let c = Client::join(welcome).unwrap();
|
||||
assert_eq!(c.access(), access, "the welcome says what the client may do");
|
||||
c
|
||||
}
|
||||
|
||||
/// Deliver everything in `out` meant for `c`; returns what it sends back.
|
||||
fn deliver(c: &mut Client, out: &[Outgoing]) -> Vec<ClientMsg> {
|
||||
let id = c.id();
|
||||
let mut replies = Vec::new();
|
||||
for o in out {
|
||||
let m = match o {
|
||||
Outgoing::To(t, m) if *t == id => m,
|
||||
Outgoing::All(m) => m,
|
||||
Outgoing::Others(not, m) if *not != id => m,
|
||||
_ => continue,
|
||||
};
|
||||
replies.extend(c.receive(m.clone(), &NoRecompute).unwrap());
|
||||
}
|
||||
replies
|
||||
}
|
||||
|
||||
fn edit(c: &mut Client, f: impl FnOnce(&mut Document)) -> ClientMsg {
|
||||
let mut d = c.document().clone();
|
||||
f(&mut d);
|
||||
c.edit(&d).unwrap().expect("a change")
|
||||
}
|
||||
|
||||
/// `c` sends `m`; everyone in `all` gets what the server says.
|
||||
fn send(s: &mut Server, from: u32, m: ClientMsg, all: &mut [&mut Client]) -> Vec<Outgoing> {
|
||||
let out = s.receive(from, m);
|
||||
for c in all.iter_mut() {
|
||||
deliver(c, &out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewer_sees_everything_and_changes_nothing() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut editor = join_as(&mut s, Access::Edit);
|
||||
let mut viewer = join_as(&mut s, Access::View);
|
||||
let page = s.document().first_page().unwrap();
|
||||
// The editor's edit reaches the viewer.
|
||||
let m = edit(&mut editor, |d| {
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
});
|
||||
send(&mut s, editor.id(), m, &mut [&mut editor, &mut viewer]);
|
||||
assert_eq!(viewer.view(), s.replica());
|
||||
// The viewer's edit is refused with the reason, and rolled back.
|
||||
let seq = s.seq();
|
||||
let m = edit(&mut viewer, |d| d.node_mut(page).unwrap().name = "Mine".into());
|
||||
let out = send(&mut s, viewer.id(), m, &mut [&mut editor, &mut viewer]);
|
||||
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == VIEW_ONLY));
|
||||
assert_eq!(s.seq(), seq, "nothing accepted");
|
||||
assert_eq!(viewer.view(), s.replica(), "the viewer's copy is the server's again");
|
||||
assert!(viewer.take_notices().iter().any(|n| n.message().contains("view")), "the viewer is told why");
|
||||
// Presence still goes both ways.
|
||||
let out = s.receive(viewer.id(), ClientMsg::Presence(Default::default()));
|
||||
assert!(matches!(out.as_slice(), [Outgoing::Others(_, ServerMsg::Presence { .. })]));
|
||||
// Joining again with an edit link lifts it.
|
||||
let id = viewer.id();
|
||||
s.leave(id);
|
||||
let mut again = join_as(&mut s, Access::Edit);
|
||||
let m = edit(&mut again, |d| d.node_mut(page).unwrap().name = "Now mine".into());
|
||||
let out = s.receive(again.id(), m);
|
||||
assert!(matches!(out.as_slice(), [Outgoing::All(ServerMsg::Changes(_))]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managing_messages_are_refused_on_a_session_connection() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let c = join_as(&mut s, Access::Edit);
|
||||
let out = s.receive(c.id(), ClientMsg::Admin(message::Admin::Files));
|
||||
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Refused { .. })]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_journal_replayed_over_the_last_state_restores_the_server() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
s.journaling(true);
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
let mut v = join_as(&mut s, Access::View);
|
||||
let page = s.document().first_page().unwrap();
|
||||
// Saved here: what comes after is only in the journal.
|
||||
let saved = s.state().unwrap();
|
||||
s.take_journal();
|
||||
let mut b = join_as(&mut s, Access::Edit);
|
||||
for i in 0..5 {
|
||||
let m = edit(&mut a, |d| {
|
||||
d.create(page, NodeKind::Rectangle, &format!("A{i}"), f64::from(i), 0.0, 10.0, 10.0).unwrap();
|
||||
});
|
||||
send(&mut s, a.id(), m, &mut [&mut a, &mut b, &mut v]);
|
||||
}
|
||||
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = "Bo's page".into());
|
||||
send(&mut s, b.id(), m, &mut [&mut a, &mut b, &mut v]);
|
||||
let m = edit(&mut v, |d| d.node_mut(page).unwrap().name = "Refused".into());
|
||||
send(&mut s, v.id(), m, &mut [&mut a, &mut b, &mut v]);
|
||||
let journal = s.take_journal();
|
||||
assert_eq!(journal.iter().filter(|j| matches!(j, Journal::Issued { .. })).count(), 1, "b's id");
|
||||
assert_eq!(journal.iter().filter(|j| matches!(j, Journal::Accepted(_))).count(), 6);
|
||||
assert_eq!(journal.iter().filter(|j| matches!(j, Journal::Refused { .. })).count(), 1);
|
||||
assert!(s.take_journal().is_empty(), "taken");
|
||||
// The entries survive both encodings (the store writes the binary one).
|
||||
for j in &journal {
|
||||
assert_eq!(&message::decode::<Journal>(&message::encode(j).unwrap(), usize::MAX).unwrap(), j);
|
||||
}
|
||||
// Restart: the saved state, then the journal.
|
||||
let mut r = Server::restore(saved, "", Limits::default()).unwrap();
|
||||
for j in journal.iter().cloned() {
|
||||
r.replay(j).unwrap();
|
||||
}
|
||||
assert_eq!(r.replica(), s.replica());
|
||||
assert_eq!(r.seq(), s.seq());
|
||||
let mut live = s.state().unwrap();
|
||||
let mut back = r.state().unwrap();
|
||||
// The session id is kept; the rest of the lasting state matches too.
|
||||
assert_eq!(live.session, back.session);
|
||||
live.snapshot.clear();
|
||||
back.snapshot.clear();
|
||||
assert_eq!(live, back);
|
||||
// Replaying again changes nothing (a log that outlived a compaction).
|
||||
for j in journal {
|
||||
r.replay(j).unwrap();
|
||||
}
|
||||
assert_eq!(r.replica(), s.replica());
|
||||
assert!(r.take_journal().is_empty(), "replays aren't journaled");
|
||||
// Every client resumes into the restarted server with nothing to send again, and a newcomer
|
||||
// gets an id nobody had.
|
||||
for c in [&mut a, &mut b] {
|
||||
c.disconnect();
|
||||
let ClientMsg::Hello(h) = c.reconnect(hello()) else { panic!() };
|
||||
let (id, out) = r.join(h).unwrap();
|
||||
assert_eq!(id, c.id());
|
||||
assert!(deliver(c, &out).is_empty());
|
||||
assert_eq!(c.view(), r.replica());
|
||||
}
|
||||
let n = join_as(&mut r, Access::Edit);
|
||||
assert!(n.id() > b.id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_journal_with_a_gap_is_refused() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
s.journaling(true);
|
||||
let saved = s.state().unwrap();
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
let page = s.document().first_page().unwrap();
|
||||
for name in ["One", "Two"] {
|
||||
let m = edit(&mut a, |d| d.node_mut(page).unwrap().name = name.into());
|
||||
send(&mut s, a.id(), m, &mut [&mut a]);
|
||||
}
|
||||
let journal: Vec<Journal> = s.take_journal().into_iter().filter(|j| !matches!(j, Journal::Accepted(acc) if acc.seq == 1)).collect();
|
||||
let mut r = Server::restore(saved, "", Limits::default()).unwrap();
|
||||
let errors: Vec<_> = journal.into_iter().filter_map(|j| r.replay(j).err()).collect();
|
||||
assert_eq!(errors.len(), 1, "the batch after the gap");
|
||||
assert_eq!(r.seq(), 0);
|
||||
}
|
||||
|
|
@ -38,9 +38,15 @@ pub use client::{Client, HISTORY_LIMIT};
|
|||
pub use diff::{Diff, diff};
|
||||
pub use figcraft_doc::{Document, NodeId};
|
||||
pub use history::{BatchKind, Notice};
|
||||
pub use message::{Accepted, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg};
|
||||
pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg};
|
||||
pub use replica::{Applied, Mode, Replica};
|
||||
pub use server::{Outgoing, Server, SessionState};
|
||||
pub use server::{Journal, Outgoing, Server, SessionState, VIEW_ONLY};
|
||||
|
||||
/// Compare secrets (tokens, keys, their hashes) without stopping at the first difference.
|
||||
pub fn same_secret(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// What can go wrong syncing.
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
|
|
@ -119,6 +125,8 @@ mod entry_ids_tests;
|
|||
#[cfg(test)]
|
||||
mod frac_tests;
|
||||
#[cfg(test)]
|
||||
mod journal_tests;
|
||||
#[cfg(test)]
|
||||
mod reconnect_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ use serde::{Deserialize, Serialize};
|
|||
use crate::Error;
|
||||
use crate::change::Change;
|
||||
|
||||
/// The protocol version a client announces in [`Hello`].
|
||||
pub const PROTOCOL: u32 = 1;
|
||||
/// The protocol version a client announces in [`Hello`] (2: the welcome says what you may do, and
|
||||
/// connections can manage a server).
|
||||
pub const PROTOCOL: u32 = 2;
|
||||
|
||||
/// The first byte of every binary message.
|
||||
pub const WIRE_VERSION: u8 = 1;
|
||||
|
|
@ -32,6 +33,35 @@ pub struct Hello {
|
|||
pub resume: Option<Resume>,
|
||||
}
|
||||
|
||||
/// What a participant may do: edit, or only view (a view link). A viewer gets everyone's changes
|
||||
/// and presence, and the server refuses every batch it sends.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Access {
|
||||
#[default]
|
||||
Edit,
|
||||
View,
|
||||
}
|
||||
|
||||
impl Access {
|
||||
/// `"edit"` or `"view"`.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Access::Edit => "edit",
|
||||
Access::View => "view",
|
||||
}
|
||||
}
|
||||
|
||||
/// From [`id`](Self::id) (any case, surrounding spaces ignored).
|
||||
pub fn parse(s: &str) -> Option<Access> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"edit" => Some(Access::Edit),
|
||||
"view" => Some(Access::View),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a reconnecting client tells the server in its [`Hello`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Resume {
|
||||
|
|
@ -115,6 +145,93 @@ pub enum CatchUp {
|
|||
Delta(Vec<Accepted>),
|
||||
}
|
||||
|
||||
/// Opens a connection that manages a server's files (instead of [`Hello`]): the server's admin
|
||||
/// key. Answered with [`AdminReply::Ready`] or `Refused`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manage {
|
||||
pub protocol: u32,
|
||||
pub key: String,
|
||||
pub client_version: String,
|
||||
}
|
||||
|
||||
/// A request on a managing connection; each gets one [`AdminReply`], in order.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Admin {
|
||||
/// Keep a document as a new file: its name and its snapshot
|
||||
/// ([`Replica::snapshot`](crate::Replica::snapshot)). The reply has a new edit link and a new
|
||||
/// view link for it.
|
||||
Publish { name: String, snapshot: Vec<Change> },
|
||||
/// The files kept.
|
||||
Files,
|
||||
/// A file's links (without their tokens).
|
||||
Links { file: String },
|
||||
/// A new link to a file.
|
||||
CreateLink { file: String, access: Access, label: String },
|
||||
/// Revoke a link: it no longer joins, and whoever joined with it is disconnected.
|
||||
Revoke { file: String, link: String },
|
||||
/// Remove a file and its links (whoever is in it is disconnected).
|
||||
Remove { file: String },
|
||||
}
|
||||
|
||||
/// A file a server keeps.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FileInfo {
|
||||
/// Its id (in links: `file=<id>`).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Seconds since 1970 (UTC).
|
||||
pub created: u64,
|
||||
pub updated: u64,
|
||||
/// The sequence number of its last accepted batch.
|
||||
pub seq: u64,
|
||||
/// Bytes on disk.
|
||||
pub bytes: u64,
|
||||
/// People in it now.
|
||||
pub people: u32,
|
||||
}
|
||||
|
||||
/// A share link (the server keeps only a hash of its token).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LinkInfo {
|
||||
/// Its id (to revoke it by; not a secret).
|
||||
pub id: String,
|
||||
pub file: String,
|
||||
pub access: Access,
|
||||
pub label: String,
|
||||
/// Seconds since 1970 (UTC).
|
||||
pub created: u64,
|
||||
}
|
||||
|
||||
/// A link just made, with its token: shown this once.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NewLink {
|
||||
pub link: LinkInfo,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// The answer to an [`Admin`] request (the first one on a connection answers [`Manage`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AdminReply {
|
||||
/// The key is right: requests may follow.
|
||||
Ready,
|
||||
Published {
|
||||
file: FileInfo,
|
||||
edit: NewLink,
|
||||
view: NewLink,
|
||||
},
|
||||
Files(Vec<FileInfo>),
|
||||
Links(Vec<LinkInfo>),
|
||||
Link(NewLink),
|
||||
/// Done (a revoke or a removal).
|
||||
Done,
|
||||
/// The request couldn't be done (the connection stays open).
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Client → server.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -133,6 +250,10 @@ pub enum ClientMsg {
|
|||
seq: u64,
|
||||
},
|
||||
Ping,
|
||||
/// Manage the server's files (the first message on a connection, instead of `Hello`).
|
||||
Manage(Manage),
|
||||
/// A request on a managing connection.
|
||||
Admin(Admin),
|
||||
}
|
||||
|
||||
/// Server → client.
|
||||
|
|
@ -142,7 +263,7 @@ pub enum ServerMsg {
|
|||
/// Joined: the session's identity (a server that restarted without its saved state is a new
|
||||
/// session), this connection's client id, the current sequence number, the document (or the
|
||||
/// batches missed since a reconnecting client's last sequence number), what the server did
|
||||
/// with this client's batches so far and who else is here.
|
||||
/// with this client's batches so far, who else is here and what this client may do.
|
||||
Welcome {
|
||||
session: u64,
|
||||
client_id: u32,
|
||||
|
|
@ -150,6 +271,7 @@ pub enum ServerMsg {
|
|||
catch_up: CatchUp,
|
||||
processed: Processed,
|
||||
peers: Vec<Peer>,
|
||||
access: Access,
|
||||
},
|
||||
/// Accepted changes, in order, to everyone (the author's own come back as confirmation).
|
||||
Changes(Accepted),
|
||||
|
|
@ -175,6 +297,8 @@ pub enum ServerMsg {
|
|||
reason: String,
|
||||
},
|
||||
Pong,
|
||||
/// The answer to a managing connection's request.
|
||||
Admin(Box<AdminReply>),
|
||||
}
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ use figcraft_doc::{Document, NodeId};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::change::{Change, Entry, Prop, Target};
|
||||
use crate::message::{Accepted, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg};
|
||||
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg};
|
||||
use crate::replica::{Mode, Replica, create_change};
|
||||
use crate::{Error, Limits, docprops};
|
||||
use crate::{Error, Limits, docprops, same_secret};
|
||||
|
||||
/// A message for the transport to deliver.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -22,6 +22,23 @@ pub enum Outgoing {
|
|||
Others(u32, ServerMsg),
|
||||
}
|
||||
|
||||
/// Why a viewer's batch is refused.
|
||||
pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)";
|
||||
|
||||
/// What changed a server's lasting state, in order: what a store appends to its log between
|
||||
/// snapshots ([`Server::journaling`]) and replays over the last snapshot after a restart
|
||||
/// ([`Server::replay`]). Presence and who is connected aren't in it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Journal {
|
||||
/// A client id was handed out.
|
||||
Issued { client: u32 },
|
||||
/// A batch was accepted.
|
||||
Accepted(Accepted),
|
||||
/// A client's batch was refused.
|
||||
Refused { client: u32, batch_id: u64 },
|
||||
}
|
||||
|
||||
/// A session server for one file.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Server {
|
||||
|
|
@ -48,6 +65,10 @@ pub struct Server {
|
|||
log: VecDeque<Accepted>,
|
||||
/// Changes in `log`.
|
||||
log_changes: usize,
|
||||
/// The connected clients that may only view.
|
||||
viewers: BTreeSet<u32>,
|
||||
/// What changed the lasting state since it was last taken (`None`: not kept).
|
||||
journal: Option<Vec<Journal>>,
|
||||
}
|
||||
|
||||
/// The most client ids a session remembers for reconnecting (the oldest disconnected ones go
|
||||
|
|
@ -92,6 +113,8 @@ impl Server {
|
|||
issued: BTreeMap::new(),
|
||||
log: VecDeque::new(),
|
||||
log_changes: 0,
|
||||
viewers: BTreeSet::new(),
|
||||
journal: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +154,66 @@ impl Server {
|
|||
Ok(server)
|
||||
}
|
||||
|
||||
/// Keep a [`Journal`] of what changes the lasting state (`on`), or stop (dropping what wasn't
|
||||
/// taken).
|
||||
pub fn journaling(&mut self, on: bool) {
|
||||
self.journal = on.then(|| self.journal.take().unwrap_or_default());
|
||||
}
|
||||
|
||||
/// What changed the lasting state since the last call, oldest first.
|
||||
pub fn take_journal(&mut self) -> Vec<Journal> {
|
||||
self.journal.as_mut().map(std::mem::take).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn note(&mut self, j: Journal) {
|
||||
if let Some(v) = self.journal.as_mut() {
|
||||
v.push(j);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a journal entry over a restored state (after a restart). What the state already has
|
||||
/// (a batch at or below its sequence number, a client id it knows, a refusal it noted)
|
||||
/// changes nothing; a batch that doesn't follow the sequence number, or doesn't apply, is an
|
||||
/// error (the rest of that log can't be trusted). Replayed entries aren't journaled again.
|
||||
pub fn replay(&mut self, j: Journal) -> Result<(), Error> {
|
||||
match j {
|
||||
Journal::Issued { client } => {
|
||||
if let std::collections::btree_map::Entry::Vacant(v) = self.issued.entry(client) {
|
||||
v.insert(Processed::default());
|
||||
self.next_client = self.next_client.max(client.saturating_add(1));
|
||||
self.forget_oldest();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()),
|
||||
Journal::Accepted(acc) => {
|
||||
if acc.seq != self.seq + 1 {
|
||||
return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq)));
|
||||
}
|
||||
self.accept(acc.author, self.seq, &acc.changes)?;
|
||||
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
|
||||
self.next_client = self.next_client.max(acc.author.saturating_add(1));
|
||||
self.remember(acc);
|
||||
Ok(())
|
||||
}
|
||||
Journal::Refused { client, batch_id } => {
|
||||
if let Some(p) = self.issued.get_mut(&client).filter(|p| batch_id > p.last_batch) {
|
||||
p.note(batch_id, true);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Beyond [`MAX_REMEMBERED`] client ids, forget the oldest one that isn't connected.
|
||||
fn forget_oldest(&mut self) {
|
||||
if self.issued.len() > MAX_REMEMBERED
|
||||
&& let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k))
|
||||
{
|
||||
self.issued.remove(&old);
|
||||
}
|
||||
}
|
||||
|
||||
/// The authoritative document.
|
||||
pub fn document(&self) -> &Document {
|
||||
self.state.document()
|
||||
|
|
@ -163,6 +246,11 @@ impl Server {
|
|||
/// snapshot when they are no longer in the log; an id this session never gave out joins as
|
||||
/// new.
|
||||
pub fn join(&mut self, hello: Hello) -> Result<(u32, Vec<Outgoing>), ServerMsg> {
|
||||
self.join_as(hello, Access::Edit)
|
||||
}
|
||||
|
||||
/// [`join`](Self::join) with what the client may do: every batch of a viewer is refused.
|
||||
pub fn join_as(&mut self, hello: Hello, access: Access) -> Result<(u32, Vec<Outgoing>), ServerMsg> {
|
||||
let refuse = |reason: &str| ServerMsg::Refused { reason: reason.to_string() };
|
||||
if hello.protocol != PROTOCOL {
|
||||
return Err(refuse(&format!("protocol {} isn't supported (this server speaks {PROTOCOL})", hello.protocol)));
|
||||
|
|
@ -192,11 +280,8 @@ impl Server {
|
|||
let id = self.next_client;
|
||||
self.next_client += 1;
|
||||
self.issued.insert(id, Processed::default());
|
||||
if self.issued.len() > MAX_REMEMBERED
|
||||
&& let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k))
|
||||
{
|
||||
self.issued.remove(&old);
|
||||
}
|
||||
self.forget_oldest();
|
||||
self.note(Journal::Issued { client: id });
|
||||
id
|
||||
}
|
||||
};
|
||||
|
|
@ -204,8 +289,13 @@ impl Server {
|
|||
self.presence.remove(&id);
|
||||
let peer = Peer { client_id: id, name: hello.name, color: hello.color };
|
||||
let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect();
|
||||
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others };
|
||||
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access };
|
||||
self.peers.insert(id, peer.clone());
|
||||
if access == Access::View {
|
||||
self.viewers.insert(id);
|
||||
} else {
|
||||
self.viewers.remove(&id);
|
||||
}
|
||||
Ok((id, vec![Outgoing::To(id, welcome), Outgoing::Others(id, ServerMsg::PeerJoined(peer))]))
|
||||
}
|
||||
|
||||
|
|
@ -213,6 +303,7 @@ impl Server {
|
|||
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
|
||||
self.presence.remove(&id);
|
||||
self.acked.remove(&id);
|
||||
self.viewers.remove(&id);
|
||||
match self.peers.remove(&id) {
|
||||
Some(_) => vec![Outgoing::Others(id, ServerMsg::PeerLeft { client_id: id })],
|
||||
None => Vec::new(),
|
||||
|
|
@ -230,7 +321,7 @@ impl Server {
|
|||
// client learned what happened to it from the catch-up.
|
||||
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
|
||||
ClientMsg::Changes { base_seq, batch_id, changes } => {
|
||||
let result = self.accept(from, base_seq, &changes);
|
||||
let result = if self.viewers.contains(&from) { Err(Error::Invalid(VIEW_ONLY.into())) } else { self.accept(from, base_seq, &changes) };
|
||||
if let Some(p) = self.issued.get_mut(&from) {
|
||||
p.note(batch_id, result.is_err());
|
||||
}
|
||||
|
|
@ -238,9 +329,11 @@ impl Server {
|
|||
Ok(()) => {
|
||||
let acc = Accepted { seq: self.seq, author: from, batch_id, changes };
|
||||
self.remember(acc.clone());
|
||||
self.note(Journal::Accepted(acc.clone()));
|
||||
vec![Outgoing::All(ServerMsg::Changes(acc))]
|
||||
}
|
||||
Err(e) => {
|
||||
self.note(Journal::Refused { client: from, batch_id });
|
||||
let fixes = self.fixes(&changes).unwrap_or_default();
|
||||
vec![Outgoing::To(from, ServerMsg::Reject { batch_id, reason: reason(&e), fixes })]
|
||||
}
|
||||
|
|
@ -255,6 +348,9 @@ impl Server {
|
|||
Vec::new()
|
||||
}
|
||||
ClientMsg::Ping => vec![Outgoing::To(from, ServerMsg::Pong)],
|
||||
ClientMsg::Manage(_) | ClientMsg::Admin(_) => {
|
||||
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files needs a connection of its own".into() })]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -421,9 +517,3 @@ fn reason(e: &Error) -> String {
|
|||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare secrets without stopping at the first difference.
|
||||
fn same_secret(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pub static MENU: &[MenuItem] = &[
|
|||
m!("File", SEP, ""),
|
||||
// FigCraft's own (live sessions): not in the reference menu, so not counted by `parity`.
|
||||
m!("File", "Join live session…", "collab.join"),
|
||||
m!("File", "Publish to server…", "collab.publish"),
|
||||
m!("File", SEP, ""),
|
||||
m!("File", "Export…", "file.export"),
|
||||
m!("File", "Export frames to PDF…", "file.exportFramesPdf"),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use serde_json::{Value, json};
|
|||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::tools::ToolId;
|
||||
use crate::{Result, Session, ViewState, bad, spec};
|
||||
use crate::{EngineError, Result, Session, ViewState, bad, spec};
|
||||
|
||||
fn view(s: &mut Session) -> Result<&mut ViewState> {
|
||||
Ok(&mut s.ds_mut()?.view)
|
||||
|
|
@ -125,6 +125,10 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
|
|||
}
|
||||
|
||||
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only.
|
||||
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
|
||||
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
|
||||
}
|
||||
s.set_tool(t);
|
||||
Ok(json!({"tool": t.id()}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -621,7 +621,8 @@ impl Session {
|
|||
let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
|
||||
let result = guard::run(id, || (spec.run)(self, params))
|
||||
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
|
||||
.and_then(|v| self.check_preview(id, &before).map(|()| v));
|
||||
.and_then(|v| self.check_preview(id, &before).map(|()| v))
|
||||
.and_then(|v| self.check_live_access(id, &before).map(|()| v));
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
self.after_command(spec, before);
|
||||
|
|
@ -658,6 +659,17 @@ impl Session {
|
|||
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
|
||||
}
|
||||
|
||||
/// A live session's document you may only view (or that is on its way to a server) is
|
||||
/// read-only: refuse an edit (it is rolled back).
|
||||
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
|
||||
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
|
||||
match self.live_read_only(*uid) {
|
||||
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) => Err(EngineError::Disabled(id.to_string(), why.into())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Instances can't gain, lose or reorder layers, and library copies are read-only: refuse such
|
||||
/// an edit (it is rolled back).
|
||||
fn check_instance_structure(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
|
|
@ -732,6 +744,9 @@ impl Session {
|
|||
/// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot.
|
||||
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
|
||||
textpreview::end(self);
|
||||
if let Some(why) = self.active_read_only() {
|
||||
return Err(EngineError::Disabled(label.to_string(), why.into()));
|
||||
}
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.preview.is_some() {
|
||||
return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into()));
|
||||
|
|
@ -1174,6 +1189,8 @@ mod keymap_tests;
|
|||
#[cfg(test)]
|
||||
mod library_variables_tests;
|
||||
#[cfg(test)]
|
||||
mod live_access_tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_tests;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@
|
|||
//! through the client and installed with [`Session::apply_remote`], which never touches the undo
|
||||
//! history. After a dropped connection the client resumes with its id and catches up; what it
|
||||
//! made meanwhile goes out then.
|
||||
//!
|
||||
//! A file can also live on a `figcraft-server`: `collab.publish` uploads the open document and
|
||||
//! syncs that tab through the server, whose share links let others edit or only view. A view link
|
||||
//! makes the synced document read-only here (every edit is refused before it is sent; the server
|
||||
//! refuses it too).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::IpAddr;
|
||||
|
|
@ -23,6 +28,8 @@ use std::sync::Arc;
|
|||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::message::{ClientMsg, EditingText, ServerMsg};
|
||||
|
||||
pub use figcraft_collab::message::Access;
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -32,6 +39,7 @@ pub use figcraft_collab::message::{Peer, Presence};
|
|||
use crate::cmd::CommandSpec;
|
||||
use crate::collab::EngineRecompute;
|
||||
use crate::params::{get, num, req_string, string};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{DocState, EngineError, Result, Session, TextEdit, ViewState, bad, spec};
|
||||
|
||||
/// Most selected ids one presence update carries (the rest aren't shown to others).
|
||||
|
|
@ -127,10 +135,28 @@ pub struct SessionInfo {
|
|||
/// invite.
|
||||
pub link: String,
|
||||
pub url: String,
|
||||
/// `ip:port` the session listens on (host) or connects to (guest).
|
||||
/// `ip:port` the session listens on (host) or connects to (guest); a server's URL when it
|
||||
/// isn't plain `ws://` at `/`.
|
||||
pub address: String,
|
||||
/// Reachable from other machines (the host listens on a network address, not loopback).
|
||||
pub network: bool,
|
||||
/// What you may do: edit, or only view (a server's view link).
|
||||
pub access: Access,
|
||||
}
|
||||
|
||||
/// A file you published to a server: where, and its first share links (they carry their tokens:
|
||||
/// share them like passwords; the server keeps only hashes).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Published {
|
||||
/// The server's address.
|
||||
pub server: String,
|
||||
/// The file's id and name on the server.
|
||||
pub file: String,
|
||||
pub name: String,
|
||||
/// The link that lets people edit, and the one that lets them only view.
|
||||
pub edit: String,
|
||||
pub view: String,
|
||||
}
|
||||
|
||||
/// What happened in the session since the last [`LiveLink::poll`].
|
||||
|
|
@ -152,6 +178,8 @@ pub enum Event {
|
|||
Ended {
|
||||
reason: String,
|
||||
},
|
||||
/// The document is on the server (`collab.publish`); joining it follows.
|
||||
Published(Published),
|
||||
}
|
||||
|
||||
/// Which network interface a hosted session listens on.
|
||||
|
|
@ -177,6 +205,21 @@ pub struct HostOptions {
|
|||
pub doc: Arc<Document>,
|
||||
}
|
||||
|
||||
/// What [`LiveLink::publish`] needs to put the open file on a server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PublishOptions {
|
||||
/// The server's address (`ws://host:port/`, `wss://host/path/`), which may carry its key
|
||||
/// (`?key=…`).
|
||||
pub server: String,
|
||||
/// The server's key, when the address doesn't carry it.
|
||||
pub key: Option<String>,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
/// Your name in the session (empty: the server picks one).
|
||||
pub me: String,
|
||||
pub doc: Arc<Document>,
|
||||
}
|
||||
|
||||
/// The networking behind live sessions, installed by the app ([`Live::install`]). Calls return
|
||||
/// at once; what happens later arrives through [`poll`](Self::poll).
|
||||
pub trait LiveLink: Send {
|
||||
|
|
@ -189,6 +232,12 @@ pub trait LiveLink: Send {
|
|||
/// Start joining the session behind an invite link (ending any current one). Errors are for
|
||||
/// links that can't be used; they never echo the link (it holds the token).
|
||||
fn join(&mut self, link: &str, name: &str) -> std::result::Result<(), String>;
|
||||
/// Start uploading `opts.doc` to a server as a new file, then join it with its edit link
|
||||
/// (ending any current session): [`Event::Published`] with its links, then the welcome.
|
||||
/// Errors are for addresses that can't be used; they never echo the key.
|
||||
fn publish(&mut self, _opts: PublishOptions) -> std::result::Result<(), String> {
|
||||
Err("publishing to a server needs the desktop app or the MCP server".into())
|
||||
}
|
||||
/// End the current session (stop hosting or leave).
|
||||
fn leave(&mut self);
|
||||
/// Your latest presence (the link throttles and coalesces).
|
||||
|
|
@ -238,6 +287,8 @@ pub struct Live {
|
|||
/// Registers the app as the opener of `figcraft://` invite links (the desktop app on Windows
|
||||
/// installs one; see [`crate::invite_links`]).
|
||||
pub links: Option<Box<dyn crate::invite_links::LinkRegistrar>>,
|
||||
/// The file you published to a server in this session, with its links.
|
||||
pub published: Option<Published>,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
|
|
@ -274,6 +325,7 @@ impl Live {
|
|||
self.synced = None;
|
||||
self.deferred.clear();
|
||||
self.hold = 0;
|
||||
self.published = None;
|
||||
}
|
||||
|
||||
/// The document sync client (once the welcome arrived): peers, pending batches, the user's
|
||||
|
|
@ -349,6 +401,11 @@ pub fn editing_hint(names: &[&str]) -> Option<String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Why an edit of a server file you joined with a view link is refused.
|
||||
pub const VIEW_ONLY: &str = "View only: the link you joined with lets you see this file, not change it";
|
||||
/// Why an edit of a file on its way to a server is refused.
|
||||
pub const PUBLISHING: &str = "The file is being published: you can edit it again once it is on the server";
|
||||
|
||||
/// A gesture, version preview or live text preview shows a document that isn't the edit to
|
||||
/// sync yet.
|
||||
fn busy(ds: &DocState) -> bool {
|
||||
|
|
@ -504,6 +561,10 @@ impl Session {
|
|||
self.message = Some(reason);
|
||||
live.reset();
|
||||
}
|
||||
Event::Published(p) => {
|
||||
self.message = Some(format!("Published {} to the server", p.name));
|
||||
live.published = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -533,8 +594,19 @@ impl Session {
|
|||
self.live.synced = None;
|
||||
}
|
||||
(Some(Role::Guest), Some(uid)) => {
|
||||
// Joining again in this tab, or the file you published arriving from the server.
|
||||
let doc = Arc::new(client.document().clone());
|
||||
let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty);
|
||||
self.apply_remote(uid, doc.clone());
|
||||
if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) {
|
||||
// Undo is per user from now on: earlier whole-document steps would revert
|
||||
// others' edits.
|
||||
ds.history.undo.clear();
|
||||
ds.history.redo.clear();
|
||||
if !was_dirty {
|
||||
ds.mark_saved();
|
||||
}
|
||||
}
|
||||
self.live.synced = Some(doc);
|
||||
}
|
||||
(Some(Role::Guest), None) => {
|
||||
|
|
@ -552,6 +624,10 @@ impl Session {
|
|||
info.file_name = doc.name.clone();
|
||||
info.pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
|
||||
}
|
||||
// A view link: the editing tools are off.
|
||||
if client.access() == Access::View && !matches!(self.tool.tool, ToolId::Move | ToolId::Hand) {
|
||||
self.set_tool(ToolId::Move);
|
||||
}
|
||||
self.live.client = Some(client);
|
||||
self.live.deferred.clear();
|
||||
}
|
||||
|
|
@ -573,6 +649,25 @@ impl Session {
|
|||
self.live_edit(None);
|
||||
}
|
||||
|
||||
/// Why document `uid` can't be edited now, if it can't: it is the session's document and you
|
||||
/// may only view it, or it is on its way to a server.
|
||||
pub fn live_read_only(&self, uid: u64) -> Option<&'static str> {
|
||||
if self.live.doc_uid != Some(uid) {
|
||||
return None;
|
||||
}
|
||||
match &self.live.client {
|
||||
Some(c) if c.access() == Access::View => Some(VIEW_ONLY),
|
||||
Some(_) => None,
|
||||
None if self.live.status.active() && self.live.role() != Some(Role::Host) => Some(PUBLISHING),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The active document can't be edited now (see [`live_read_only`](Self::live_read_only)).
|
||||
pub fn active_read_only(&self) -> Option<&'static str> {
|
||||
self.ds().ok().and_then(|ds| self.live_read_only(ds.uid))
|
||||
}
|
||||
|
||||
/// Whether document `uid` is the live session's synced document: its edits sync and its undo
|
||||
/// history is the user's own.
|
||||
pub fn live_doc(&self, uid: u64) -> bool {
|
||||
|
|
@ -588,6 +683,13 @@ impl Session {
|
|||
if self.live.hold > 0 || busy(ds) || self.live.synced.as_ref().is_some_and(|s| Arc::ptr_eq(s, &ds.doc)) {
|
||||
return;
|
||||
}
|
||||
if self.live_read_only(uid).is_some() {
|
||||
// Nothing goes out: what changed outside a command is put back.
|
||||
if let Some(s) = self.live.synced.clone() {
|
||||
self.apply_remote(uid, s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let doc = ds.doc.clone();
|
||||
let Some(client) = self.live.client.as_mut() else { return };
|
||||
match client.edit_with(&doc, label) {
|
||||
|
|
@ -793,6 +895,9 @@ impl Session {
|
|||
"url": info.map(|i| i.url.as_str()),
|
||||
"address": info.map(|i| i.address.as_str()),
|
||||
"network": info.map(|i| i.network),
|
||||
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
|
||||
"readOnly": self.active_read_only().is_some(),
|
||||
"published": l.published,
|
||||
"follow": l.follow,
|
||||
"syncing": l.syncing(),
|
||||
"document": l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u)),
|
||||
|
|
@ -831,6 +936,23 @@ fn host(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
fn publish(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.publish";
|
||||
let server = req_string(CMD, p, "server")?;
|
||||
let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
|
||||
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
|
||||
let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::<String>()).filter(|n| !n.is_empty());
|
||||
let opts =
|
||||
PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me: display_name(&s.prefs), doc };
|
||||
let link = s.live.link.as_mut().ok_or_else(|| unavailable(CMD))?;
|
||||
link.publish(opts).map_err(|e| bad(CMD, e))?;
|
||||
s.live.reset();
|
||||
s.live.status = Status::Connecting;
|
||||
// This tab becomes the server file's synced document once it is there.
|
||||
s.live.doc_uid = Some(uid);
|
||||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.join";
|
||||
let link_text = req_string(CMD, p, "link")?;
|
||||
|
|
@ -844,10 +966,14 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let was = s.live.status.active();
|
||||
let kept = s.live.syncing() && s.live.role() == Some(Role::Guest);
|
||||
if let Some(l) = s.live.link.as_mut() {
|
||||
l.leave();
|
||||
}
|
||||
s.live.reset();
|
||||
if kept {
|
||||
s.message = Some("You left the session; your copy stays open here".into());
|
||||
}
|
||||
Ok(json!({"ended": was}))
|
||||
}
|
||||
|
||||
|
|
@ -887,15 +1013,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"collab.join",
|
||||
"Join live session",
|
||||
"{link: figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…} → status \"connecting\"; `collab.peers` tells when it's live",
|
||||
"{link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link)} → status \"connecting\"; `collab.peers` tells when it's live",
|
||||
false,
|
||||
join
|
||||
),
|
||||
spec!("collab.leave", "Leave live session", "{} → {ended}", false, leave),
|
||||
spec!("collab.leave", "Leave live session", "{} (the synced file stays open as a local copy) → {ended}", false, leave),
|
||||
spec!(
|
||||
"collab.publish",
|
||||
"Publish to server",
|
||||
"{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view}` (share links: edit, or view only)",
|
||||
false,
|
||||
publish
|
||||
),
|
||||
spec!(
|
||||
"collab.peers",
|
||||
"People in this file",
|
||||
"{} → {available, canHost (false in the browser: it can only join), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}",
|
||||
"{} → {available, canHost (false in the browser: it can only join), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}",
|
||||
false,
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
//! Files on a server, against a scripted link: `collab.publish` makes the open tab the server
|
||||
//! file's synced document (read-only until it is there, its undo history per person after), and
|
||||
//! a view link makes the synced document read-only (commands, gestures, editing tools and changes
|
||||
//! made outside a command are all refused or put back; others' edits still arrive; other tabs stay
|
||||
//! editable). Leaving keeps the file open as your copy.
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_collab::{Client, NoRecompute, Outgoing};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live::{Access, Event, PUBLISHING, Published, Role, VIEW_ONLY};
|
||||
use crate::live_tests::{info, push, relay, serve_as, session};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn refused(s: &mut Session, cmd: &str, params: serde_json::Value, why: &str) {
|
||||
match s.execute(cmd, ¶ms) {
|
||||
Err(EngineError::Disabled(_, reason)) => assert_eq!(reason, why, "{cmd}"),
|
||||
other => panic!("{cmd}: expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishing_makes_the_open_tab_the_server_file() {
|
||||
let (mut s, script) = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
assert!(s.undo_depth().0 > 0);
|
||||
let e = s.execute("collab.publish", &json!({"server": "nope"})).unwrap_err();
|
||||
assert!(e.to_string().contains("not a server address"), "{e}");
|
||||
let r = s.execute("collab.publish", &json!({"server": " ws://127.0.0.1:47900/?key=k ", "name": " Team\n"})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
assert_eq!(r["readOnly"], true);
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!(sc.published.len(), 1);
|
||||
assert_eq!(sc.published[0].server, "ws://127.0.0.1:47900/?key=k");
|
||||
assert_eq!(sc.published[0].name, "Team");
|
||||
assert_eq!(sc.published[0].doc.node_count(), s.doc().unwrap().node_count());
|
||||
}
|
||||
// Until it is on the server nothing changes it (and the name defaults to the file's).
|
||||
refused(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 5, "height": 5}), PUBLISHING);
|
||||
refused(&mut s, "tool.set", json!({"tool": "rectangle"}), PUBLISHING);
|
||||
assert!(s.execute("tool.set", &json!({"tool": "hand"})).is_ok(), "moving around is fine");
|
||||
assert!(s.begin_interaction("Move").is_err());
|
||||
// The server's welcome: this tab is the synced document, undo is per person from now on.
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
doc.name = "Team".into();
|
||||
let (mut server, id, welcome) = serve_as(&doc, Access::Edit);
|
||||
let published = Published { server: "127.0.0.1:47900".into(), file: "f".into(), name: "Team".into(), edit: "e".into(), view: "v".into() };
|
||||
push(&script, vec![Event::Published(published.clone()), Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live.syncing());
|
||||
assert_eq!(s.docs.len(), 1, "no new tab");
|
||||
assert_eq!(s.doc().unwrap().name, "Team");
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
assert_eq!(s.active_read_only(), None);
|
||||
assert_eq!(s.live.published, Some(published));
|
||||
assert_eq!(s.message.as_deref(), Some("Published Team to the server"));
|
||||
assert_eq!(s.live_summary()["published"]["name"], "Team");
|
||||
// Edits sync through the server.
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
let answers = relay(&script, &mut server, id);
|
||||
assert!(server.document().contains(figcraft_doc::NodeId(rect)));
|
||||
push(&script, answers);
|
||||
s.live_poll();
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
// Leaving keeps the file open, yours to edit.
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
assert!(s.doc().unwrap().contains(figcraft_doc::NodeId(rect)));
|
||||
assert_eq!(s.message.as_deref(), Some("You left the session; your copy stays open here"));
|
||||
assert!(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_link_makes_the_file_read_only() {
|
||||
let (mut s, script) = session();
|
||||
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
|
||||
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
|
||||
let mut doc = figcraft_doc::Document::new("Shared");
|
||||
let page = doc.first_page().unwrap();
|
||||
let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let (mut server, id, welcome) = serve_as(&doc, Access::View);
|
||||
let mut me = info(Role::Guest, id);
|
||||
me.access = Access::View;
|
||||
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live.syncing());
|
||||
assert_eq!(s.active_read_only(), Some(VIEW_ONLY));
|
||||
let summary = s.live_summary();
|
||||
assert_eq!((summary["access"].as_str(), summary["readOnly"].as_bool()), (Some("view"), Some(true)));
|
||||
assert_eq!(s.tool.tool, ToolId::Move, "the editing tool was put away");
|
||||
// Commands, tools and gestures are refused; nothing changes and nothing is sent.
|
||||
let before = s.doc().unwrap().clone();
|
||||
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}), VIEW_ONLY);
|
||||
refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}), VIEW_ONLY);
|
||||
refused(&mut s, "edit.delete", json!({"ids": [card.0]}), VIEW_ONLY);
|
||||
refused(&mut s, "tool.set", json!({"tool": "text"}), VIEW_ONLY);
|
||||
assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
assert!(s.execute("select.set", &json!({"ids": [card.0]})).is_ok(), "selecting is fine");
|
||||
// A change made outside a command is put back, never sent.
|
||||
s.ds_mut().unwrap().doc_mut().node_mut(card).unwrap().name = "Outside".into();
|
||||
s.live_sync();
|
||||
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Card");
|
||||
assert!(script.lock().unwrap().msgs.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })));
|
||||
// Someone who may edit renames the card: it arrives.
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Bo", "") else { panic!() };
|
||||
let (bo_id, outs) = server.join(h).unwrap();
|
||||
let Some(Outgoing::To(_, w)) = outs.into_iter().next() else { panic!() };
|
||||
let mut bo = Client::join(w).unwrap();
|
||||
let mut d = bo.document().clone();
|
||||
d.node_mut(card).unwrap().name = "Bo's card".into();
|
||||
let m = bo.edit(&d).unwrap().unwrap();
|
||||
for o in server.receive(bo_id, m) {
|
||||
if let Outgoing::All(m @ ServerMsg::Changes(_)) = o {
|
||||
bo.receive(m.clone(), &NoRecompute).unwrap();
|
||||
push(&script, vec![Event::Sync(m)]);
|
||||
}
|
||||
}
|
||||
s.live_poll();
|
||||
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Bo's card");
|
||||
// Your other tabs stay yours.
|
||||
s.active = 0;
|
||||
assert_eq!(s.active_read_only(), None);
|
||||
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ use figcraft_collab::{Client, Limits, Outgoing, Server};
|
|||
use figcraft_doc::{Document, NodeId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live::{Event, HostOptions, LiveLink, Peer, Presence, Role, SessionInfo, Status};
|
||||
use crate::live::{Access, Event, HostOptions, LiveLink, Peer, Presence, PublishOptions, Role, SessionInfo, Status};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// What the scripted link was asked to do, and the events it hands out next.
|
||||
|
|
@ -20,6 +20,7 @@ pub(crate) struct Script {
|
|||
joined: Vec<String>,
|
||||
left: usize,
|
||||
pub(crate) msgs: Vec<ClientMsg>,
|
||||
pub(crate) published: Vec<PublishOptions>,
|
||||
}
|
||||
|
||||
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
|
||||
|
|
@ -36,6 +37,13 @@ impl LiveLink for Mock {
|
|||
self.0.lock().unwrap().joined.push(link.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, opts: PublishOptions) -> Result<(), String> {
|
||||
if !opts.server.starts_with("ws") {
|
||||
return Err("not a server address".into());
|
||||
}
|
||||
self.0.lock().unwrap().published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self) {
|
||||
self.0.lock().unwrap().left += 1;
|
||||
}
|
||||
|
|
@ -62,6 +70,7 @@ pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
|
|||
url: "ws://127.0.0.1:47900/?file=f&token=t".into(),
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access: crate::live::Access::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,28 +78,33 @@ fn peer(id: u32, name: &str) -> Peer {
|
|||
Peer { client_id: id, name: name.into(), color: "#9747FF".into() }
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
pub(crate) fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
let mut s = Session::new();
|
||||
let script = Arc::new(Mutex::new(Script::default()));
|
||||
s.live.install(Box::new(Mock(script.clone())));
|
||||
(s, script)
|
||||
}
|
||||
|
||||
fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
|
||||
pub(crate) fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
|
||||
script.lock().unwrap().events.extend(events);
|
||||
}
|
||||
|
||||
/// A session server for `doc` and the welcome it gives a first client (as `Event::Sync`).
|
||||
fn serve(doc: &Document) -> (Server, u32, Event) {
|
||||
serve_as(doc, Access::Edit)
|
||||
}
|
||||
|
||||
/// [`serve`] for a client that may do `access`.
|
||||
pub(crate) fn serve_as(doc: &Document, access: Access) -> (Server, u32, Event) {
|
||||
let mut server = Server::new(doc.clone(), "", Limits::default());
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Me", "") else { panic!("no hello") };
|
||||
let (id, outs) = server.join(h).unwrap();
|
||||
let (id, outs) = server.join_as(h, access).unwrap();
|
||||
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
(server, id, Event::Sync(w))
|
||||
}
|
||||
|
||||
/// Feed what the session sent to the server; the server's answers for the session, in order.
|
||||
fn relay(script: &Arc<Mutex<Script>>, server: &mut Server, from: u32) -> Vec<Event> {
|
||||
pub(crate) fn relay(script: &Arc<Mutex<Script>>, server: &mut Server, from: u32) -> Vec<Event> {
|
||||
let msgs = std::mem::take(&mut script.lock().unwrap().msgs);
|
||||
let mut out = Vec::new();
|
||||
for m in msgs {
|
||||
|
|
|
|||
|
|
@ -784,6 +784,14 @@ pub fn toggle(resp: &Response, tip: &str, on: bool) {
|
|||
});
|
||||
}
|
||||
|
||||
/// A status shown in place (a badge), with what it means.
|
||||
pub fn status(resp: &Response, label: &str, description: &str) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::Status);
|
||||
n.set_description(description);
|
||||
});
|
||||
}
|
||||
|
||||
/// A tab, selected when it's the one shown.
|
||||
pub fn tab(resp: &Response, label: &str, selected: bool) {
|
||||
node(resp, label, |n| {
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
color_profile_dialog(app, ctx);
|
||||
crate::plugins_ui::show(app, ctx);
|
||||
crate::live::join_dialog(app, ctx);
|
||||
crate::live::publish_dialog(app, ctx);
|
||||
}
|
||||
|
||||
/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ mod layers_rename_tests;
|
|||
#[cfg(test)]
|
||||
mod live_join_tests;
|
||||
#[cfg(test)]
|
||||
mod live_server_tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod live_text_tests;
|
||||
|
|
@ -746,7 +748,7 @@ impl FigApp {
|
|||
present::show_pane(self, &ctx);
|
||||
comments::keys(self, &ctx);
|
||||
dev_compare::keys(self, &ctx);
|
||||
live::join_keys(self, &ctx);
|
||||
live::dialog_keys(self, &ctx);
|
||||
shortcuts::handle(self, &ctx, typing);
|
||||
menus::popups(self, &ctx);
|
||||
variable_ui::picker(self, &ctx);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
//! Live sessions in the window: other people's cursors (smoothly following their pointer, with a
|
||||
//! name tag) and selections on the canvas (their edits arrive through the engine's sync), their avatars at the top of the right panel (click one
|
||||
//! to follow their view), the Share popover (start a session, the invite link, stop) and the
|
||||
//! Join dialog. Everything acts through the `collab.*` commands; the state is the engine's
|
||||
//! to follow their view), the Share popover (start a session, the invite link, stop; the server,
|
||||
//! file and access of a session you joined; a published file's links), the Join dialog and the
|
||||
//! Publish to server dialog. Everything acts through the `collab.*` commands; the state is the engine's
|
||||
//! (`Session::live`). The network wakes the UI when something arrives, and cursors only ask for
|
||||
//! frames while they glide, so an idle session draws nothing.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_engine::live::{PEOPLE, Role, Status, initials, parse_color, presence_page};
|
||||
use figcraft_engine::live::{Access, PEOPLE, Role, Status, initials, parse_color, presence_page};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
|
|
@ -19,12 +20,17 @@ use crate::theme::{ACCENT, Tokens, regular, semibold};
|
|||
use crate::widgets;
|
||||
|
||||
/// In the browser: what joining needs (a page can't host, and https pages can't reach `ws://`).
|
||||
const BROWSER_NOTE: &str =
|
||||
"In the browser you can join sessions hosted by the desktop app. A page opened over https can only reach sessions on this computer.";
|
||||
const BROWSER_NOTE: &str = "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS).";
|
||||
|
||||
/// How undo works in a session (per person).
|
||||
const UNDO_NOTE: &str = "Undo takes back only your own changes; what others changed since stays.";
|
||||
|
||||
/// What a view link means for you.
|
||||
const VIEW_NOTE: &str = "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit.";
|
||||
|
||||
/// What a published file's links are.
|
||||
const PUBLISHED_NOTE: &str = "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them.";
|
||||
|
||||
/// How fast a remote cursor closes in on where it was last reported (per second).
|
||||
const GLIDE: f64 = 20.0;
|
||||
/// Most of one person's selection outlined.
|
||||
|
|
@ -45,6 +51,21 @@ pub struct LiveUi {
|
|||
focus_join: bool,
|
||||
/// Start sessions on the local network (else this machine only).
|
||||
pub lan: bool,
|
||||
/// The Publish to server dialog's fields (open when `Some`).
|
||||
pub publish: Option<PublishForm>,
|
||||
/// Put the keyboard in the Publish dialog's first field on its next frame.
|
||||
focus_publish: bool,
|
||||
/// The last server address published to (kept for this run only: it carries the server key).
|
||||
last_server: String,
|
||||
}
|
||||
|
||||
/// What the Publish to server dialog asks for.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PublishForm {
|
||||
/// The server's address with its key (`ws://host:47900/?key=…`).
|
||||
pub server: String,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl LiveUi {
|
||||
|
|
@ -260,34 +281,37 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
|
|||
menu_popup(anchor).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new("Live session").font(semibold(12.0)));
|
||||
let status = app.session.live.status.clone();
|
||||
let role = app.session.live.role();
|
||||
let (line, col) = match (&status, role) {
|
||||
(Status::Live, Some(Role::Host)) => (format!("Live · {}", people_count(app.session.live.peers.len())), t.success),
|
||||
(Status::Live, _) => {
|
||||
let file = app.session.live.info.as_ref().map(|i| i.file_name.clone()).unwrap_or_default();
|
||||
(if file.is_empty() { "Joined".to_string() } else { format!("Joined {file}") }, t.success)
|
||||
}
|
||||
(Status::Failed { .. }, _) => (status.label(), t.danger),
|
||||
_ => (status.label(), t.text_secondary),
|
||||
};
|
||||
widgets::label(ui, &line, col);
|
||||
match (&status, role) {
|
||||
(Status::Off | Status::Failed { .. }, _) => start_ui(app, ui, &t),
|
||||
(Status::Live, Some(Role::Host)) => host_ui(app, ui),
|
||||
_ => {
|
||||
if let Some(i) = app.session.live.info.as_ref().filter(|i| !i.pages.is_empty()) {
|
||||
let pages: Vec<&str> = i.pages.iter().map(|p| p.name.as_str()).take(8).collect();
|
||||
widgets::label(ui, &format!("Pages: {}", pages.join(", ")), t.text_secondary);
|
||||
}
|
||||
widgets::label(ui, UNDO_NOTE, t.text_secondary);
|
||||
if widgets::button(ui, "Leave session", false, true).clicked() {
|
||||
app.run("collab.leave", json!({}));
|
||||
ui.set_max_width(300.0);
|
||||
// Menus lay out justified and don't wrap: the session's notes wrap, left-aligned, within
|
||||
// the popover's width.
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new("Live session").font(semibold(12.0)));
|
||||
let status = app.session.live.status.clone();
|
||||
let role = app.session.live.role();
|
||||
let others = app.session.live.peers.len();
|
||||
let published = app.session.live.published.is_some();
|
||||
// Connecting with a tab already chosen and nobody's welcome yet: the upload.
|
||||
let publishing = status == Status::Connecting && role.is_none() && app.session.live.doc_uid.is_some();
|
||||
let (line, col) = match (&status, role) {
|
||||
(Status::Live, Some(Role::Host)) => (format!("Live · {}", people_count(others)), t.success),
|
||||
(Status::Live, _) if published => (format!("Published · {}", people_count(others)), t.success),
|
||||
(Status::Connecting, _) if publishing => ("Publishing…".to_string(), t.text_secondary),
|
||||
(Status::Live, _) => {
|
||||
let file = app.session.live.info.as_ref().map(|i| i.file_name.clone()).unwrap_or_default();
|
||||
(if file.is_empty() { "Joined".to_string() } else { format!("Joined {file}") }, t.success)
|
||||
}
|
||||
(Status::Failed { .. }, _) => (status.label(), t.danger),
|
||||
_ => (status.label(), t.text_secondary),
|
||||
};
|
||||
widgets::label(ui, &line, col);
|
||||
match (&status, role) {
|
||||
(Status::Off | Status::Failed { .. }, _) => start_ui(app, ui, &t),
|
||||
(Status::Live, Some(Role::Host)) => host_ui(app, ui),
|
||||
_ => guest_ui(app, ui, &t),
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if menu_item(ui, "Export…", "") {
|
||||
app.ui.export_preview = true;
|
||||
|
|
@ -340,12 +364,51 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
|||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
widgets::label(ui, "Or keep the file on a FigCraft server and share links to edit or to view it.", t.text_secondary);
|
||||
if widgets::button(ui, "Publish to server…", false, true).clicked() {
|
||||
open_publish(app);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosting: the invite link and URL to copy, and stop.
|
||||
fn host_ui(app: &mut FigApp, ui: &mut Ui) {
|
||||
let Some(info) = app.session.live.info.clone() else { return };
|
||||
for (label, value, what) in [("Invite link", &info.link, "Invite link copied"), ("WebSocket URL", &info.url, "WebSocket URL copied")] {
|
||||
link_rows(app, ui, &[("Invite link", &info.link, "Invite link copied"), ("WebSocket URL", &info.url, "WebSocket URL copied")]);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::label(ui, "The link carries the session's secret: share it only with people you invite.", t.text_secondary);
|
||||
widgets::label(ui, UNDO_NOTE, t.text_secondary);
|
||||
if widgets::button(ui, "Stop session", false, true).clicked() {
|
||||
app.run("collab.stop", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Joined, or publishing: where the file is and what you may do there, a published file's links,
|
||||
/// and leave (the file stays open as your copy).
|
||||
fn guest_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let live = &app.session.live;
|
||||
let view = live.info.as_ref().is_some_and(|i| i.access == Access::View);
|
||||
if let Some(i) = live.info.as_ref() {
|
||||
widgets::label(ui, &format!("{} · {}", i.address, if view { "View only" } else { "Can edit" }), t.text_secondary);
|
||||
if !i.pages.is_empty() {
|
||||
let pages: Vec<&str> = i.pages.iter().map(|p| p.name.as_str()).take(8).collect();
|
||||
widgets::label(ui, &format!("Pages: {}", pages.join(", ")), t.text_secondary);
|
||||
}
|
||||
}
|
||||
if let Some(p) = live.published.clone() {
|
||||
link_rows(app, ui, &[("Edit link", &p.edit, "Edit link copied"), ("View link", &p.view, "View link copied")]);
|
||||
widgets::label(ui, PUBLISHED_NOTE, t.text_secondary);
|
||||
}
|
||||
widgets::label(ui, if view { VIEW_NOTE } else { UNDO_NOTE }, t.text_secondary);
|
||||
if widgets::button(ui, "Leave session", false, true).on_hover_text("Your copy of the file stays open").clicked() {
|
||||
app.run("collab.leave", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Links to copy: a label, the link (elided, monospace) and a Copy button each.
|
||||
fn link_rows(app: &mut FigApp, ui: &mut Ui, rows: &[(&str, &String, &str)]) {
|
||||
for (label, value, what) in rows {
|
||||
ui.horizontal(|ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width() - 64.0;
|
||||
|
|
@ -356,16 +419,10 @@ fn host_ui(app: &mut FigApp, ui: &mut Ui) {
|
|||
});
|
||||
if widgets::button(ui, "Copy", false, true).on_hover_text(format!("Copy the {}", label.to_lowercase())).clicked() {
|
||||
app.services.clipboard_set_text(value);
|
||||
app.toast(what);
|
||||
app.toast(*what);
|
||||
}
|
||||
});
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::label(ui, "The link carries the session's secret: share it only with people you invite.", t.text_secondary);
|
||||
widgets::label(ui, UNDO_NOTE, t.text_secondary);
|
||||
if widgets::button(ui, "Stop session", false, true).clicked() {
|
||||
app.run("collab.stop", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Join dialog, with `link` in its field (focused).
|
||||
|
|
@ -382,17 +439,20 @@ pub fn open_invite(app: &mut FigApp, link: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Escape closes the Join dialog (before the canvas's shortcuts see it: it doesn't deselect).
|
||||
pub fn join_keys(app: &mut FigApp, ctx: &egui::Context) {
|
||||
if app.ui.live.join.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
app.ui.live.join = None;
|
||||
/// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't
|
||||
/// deselect).
|
||||
pub fn dialog_keys(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let live = &mut app.ui.live;
|
||||
if (live.join.is_some() || live.publish.is_some()) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
live.join = None;
|
||||
live.publish = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Join live session: paste an invite link.
|
||||
pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
// The editor takes Escape before its shortcuts; the home screen has none.
|
||||
join_keys(app, ctx);
|
||||
dialog_keys(app, ctx);
|
||||
let Some(mut link) = app.ui.live.join.take() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
|
|
@ -427,6 +487,66 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Open the Publish to server dialog for the open file (the last address used this run in it).
|
||||
pub fn open_publish(app: &mut FigApp) {
|
||||
if !app.session.live.can_host() {
|
||||
app.toast("Publishing to a server needs the desktop app; in the browser you can join a server's files");
|
||||
return;
|
||||
}
|
||||
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default();
|
||||
app.ui.live.publish = Some(PublishForm { server: app.ui.live.last_server.clone(), name });
|
||||
app.ui.live.focus_publish = true;
|
||||
}
|
||||
|
||||
/// Share ▸ Publish to server…: the server's address (with its key) and the file's name.
|
||||
pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let Some(mut form) = app.ui.live.publish.take() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
let mut cancel = false;
|
||||
let mut go = false;
|
||||
let focus = std::mem::take(&mut app.ui.live.focus_publish);
|
||||
crate::dialogs::window("Publish to server", ctx).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
widgets::label(
|
||||
ui,
|
||||
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view.",
|
||||
t.text,
|
||||
);
|
||||
widgets::label(ui, "Server address", t.text_secondary);
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut form.server)
|
||||
.hint_text("ws://server:47900/?key=…")
|
||||
.desired_width(380.0)
|
||||
.id(egui::Id::new("live_publish_server")),
|
||||
);
|
||||
// The address carries the server key: it isn't read out.
|
||||
a11y::text(&resp, "Server address", "");
|
||||
if focus {
|
||||
resp.request_focus();
|
||||
}
|
||||
let enter = |ui: &Ui| ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
go = resp.lost_focus() && enter(ui);
|
||||
widgets::label(ui, "On the server's machine, `figcraft-server key` prints it.", t.text_secondary);
|
||||
widgets::label(ui, "File name", t.text_secondary);
|
||||
let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name")));
|
||||
a11y::text(&nresp, "File name", &form.name);
|
||||
go |= nresp.lost_focus() && enter(ui);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
go |= widgets::button(ui, "Publish", true, !form.server.trim().is_empty()).clicked();
|
||||
cancel = widgets::button(ui, "Cancel", false, true).clicked();
|
||||
});
|
||||
});
|
||||
if go && !form.server.trim().is_empty() && app.run("collab.publish", json!({"server": form.server.trim(), "name": form.name.trim()})).is_some() {
|
||||
app.ui.live.last_server = form.server.trim().to_string();
|
||||
return;
|
||||
}
|
||||
if open && !cancel {
|
||||
app.ui.live.publish = Some(form);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live session state for `ui.inspect`.
|
||||
pub fn inspect(app: &FigApp) -> serde_json::Value {
|
||||
let l = &app.session.live;
|
||||
|
|
@ -437,6 +557,8 @@ pub fn inspect(app: &FigApp) -> serde_json::Value {
|
|||
"follow": l.follow,
|
||||
"cursorsShown": app.ui.live.shown(),
|
||||
"joinDialog": app.ui.live.join.is_some(),
|
||||
"publishDialog": app.ui.live.publish.is_some(),
|
||||
"readOnly": app.session.active_read_only(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,6 @@ fn the_browser_joins_but_does_not_host() {
|
|||
assert!(h.app.ui.live.join.is_some());
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
"In the browser you can join sessions hosted by the desktop app. A page opened over https can only reach sessions on this computer."
|
||||
"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)."
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
//! Files on a server in the window: the Publish to server dialog (from the File menu and the
|
||||
//! Share popover; its field takes the keyboard; Escape closes it; publishing leaves the tab
|
||||
//! read-only until the file is there), the Share popover's address, access and a published file's
|
||||
//! links, and a view link's "View only" badge with only the moving tools.
|
||||
|
||||
use egui::{Event, Key};
|
||||
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_engine::live::{Access, Event as LiveEvent, Published, Role, SessionInfo, Status, VIEW_ONLY};
|
||||
use figcraft_engine::tools::ToolId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live_tests::{Harness, has_text, text_center};
|
||||
|
||||
fn key(h: &mut Harness, key: Key) {
|
||||
for pressed in [true, false] {
|
||||
h.frame(vec![Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]);
|
||||
}
|
||||
}
|
||||
|
||||
fn focused(h: &Harness, id: &str) -> bool {
|
||||
h.ctx.memory(|m| m.has_focus(egui::Id::new(id)))
|
||||
}
|
||||
|
||||
/// Joined through a server's link with `access`: the welcome and the document (a new tab).
|
||||
fn joined(h: &mut Harness, access: Access) {
|
||||
h.app.session.execute("collab.join", &json!({"link": "figcraft://join?host=127.0.0.1:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv"})).unwrap();
|
||||
let mut server = Server::new(h.app.session.doc().unwrap().clone(), "", Limits::default());
|
||||
let ClientMsg::Hello(me) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
|
||||
let (id, outs) = server.join_as(me, access).unwrap();
|
||||
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: id,
|
||||
name: "Ada".into(),
|
||||
color: "#14AE5C".into(),
|
||||
file_name: String::new(),
|
||||
pages: vec![],
|
||||
link: String::new(),
|
||||
url: String::new(),
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access,
|
||||
};
|
||||
h.send(vec![LiveEvent::Welcome { info, peers: vec![] }, LiveEvent::Sync(w)]);
|
||||
h.frames(2);
|
||||
assert!(h.app.session.live_shared());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_publish_dialog() {
|
||||
let (mut h, _) = Harness::new();
|
||||
crate::menus::invoke(&mut h.app, "collab.publish");
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.ui.live.publish.as_ref().map(|f| f.name.as_str()), Some("Untitled"), "the file's name to start with");
|
||||
assert!(focused(&h, "live_publish_server"), "the address field takes the keyboard");
|
||||
key(&mut h, Key::Escape);
|
||||
assert!(h.app.ui.live.publish.is_none(), "Escape closes it");
|
||||
// From the Share popover, then publish.
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Or keep the file on a FigCraft server and share links to edit or to view it."));
|
||||
h.click(text_center(&shapes, "Publish to server…"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "On the server's machine, `figcraft-server key` prints it."));
|
||||
h.frame(vec![Event::Text("ws://127.0.0.1:47900/?key=KeyKeyKeyKeyKeyKeyKey0".into())]);
|
||||
let shapes = h.frames(1);
|
||||
h.click(text_center(&shapes, "Publish"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(h.app.ui.live.publish.is_none(), "it closes once publishing starts");
|
||||
assert_eq!(h.app.session.live.status, Status::Connecting);
|
||||
assert!(has_text(&shapes, "Publishing…"), "the toolbar says why the file can't be edited yet");
|
||||
assert_eq!(crate::live::inspect(&h.app)["readOnly"], figcraft_engine::live::PUBLISHING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_published_file_shows_its_links() {
|
||||
let (mut h, _) = Harness::new();
|
||||
joined(&mut h, Access::Edit);
|
||||
h.app.session.live.published = Some(Published {
|
||||
server: "127.0.0.1:47900".into(),
|
||||
file: "f".into(),
|
||||
name: "Team".into(),
|
||||
edit: "figcraft://join?host=127.0.0.1:47900&file=f&token=EditEditEditEditEdit00".into(),
|
||||
view: "figcraft://join?host=127.0.0.1:47900&file=f&token=ViewViewViewViewView00".into(),
|
||||
});
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Published · nobody else here yet"));
|
||||
assert!(has_text(&shapes, "127.0.0.1:47900 · Can edit"));
|
||||
assert!(has_text(&shapes, "Edit link") && has_text(&shapes, "View link"));
|
||||
assert!(has_text(&shapes, "Leave session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_link_shows_the_badge_and_only_the_moving_tools() {
|
||||
let (mut h, _) = Harness::new();
|
||||
h.app.session.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
|
||||
joined(&mut h, Access::View);
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "View only"), "the badge");
|
||||
assert_eq!(h.app.session.tool.tool, ToolId::Move);
|
||||
assert_eq!(crate::live::inspect(&h.app)["readOnly"], VIEW_ONLY);
|
||||
// A tool's shortcut changes nothing, and says why.
|
||||
key(&mut h, Key::R);
|
||||
assert_eq!(h.app.session.tool.tool, ToolId::Move);
|
||||
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("View only")));
|
||||
// The Share popover says what the link allows.
|
||||
let shapes = h.frames(1);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "127.0.0.1:47900 · View only"));
|
||||
assert!(has_text(&shapes, "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit."));
|
||||
// Leaving: the copy is yours, the full toolbar is back.
|
||||
h.click(text_center(&shapes, "Leave session"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(!has_text(&shapes, "View only"));
|
||||
assert!(h.app.session.execute("tool.set", &json!({"tool": "rectangle"})).is_ok());
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@ impl LiveLink for Mock {
|
|||
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, _: figcraft_engine::live::PublishOptions) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self) {}
|
||||
fn presence(&mut self, _: &Presence) {}
|
||||
fn send(&mut self, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
|
|
@ -115,6 +118,7 @@ impl Harness {
|
|||
url: String::new(),
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access: figcraft_engine::live::Access::Edit,
|
||||
};
|
||||
self.send(vec![LiveEvent::Welcome { info, peers: vec![Peer { client_id: 1, name: "Hana".into(), color: "#9747FF".into() }] }]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ pub fn invoke(app: &mut FigApp, id: &str) {
|
|||
"edit.findReplace" => app.ui.find = Some((String::new(), String::new())),
|
||||
// From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly).
|
||||
"collab.join" => crate::live::open_join(app, ""),
|
||||
// So is the server address for publishing (it carries the server key).
|
||||
"collab.publish" => crate::live::open_publish(app),
|
||||
"edit.copy" | "edit.cut" => {
|
||||
let ids = app.session.selection();
|
||||
// SVG for other apps, taken before a cut removes the layers.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId {
|
|||
pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let canvas = app.canvas.rect;
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only.
|
||||
if let Some(why) = app.session.active_read_only() {
|
||||
view_only_toolbar(app, ui, why);
|
||||
return;
|
||||
}
|
||||
// Vector edit mode swaps the tools for the edit tools.
|
||||
if let Some(tool) = app.session.ds().ok().and_then(|d| d.vector_edit.as_ref()).map(|v| v.tool) {
|
||||
edit_toolbar(app, ui, tool);
|
||||
|
|
@ -182,6 +187,43 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
});
|
||||
}
|
||||
|
||||
/// The pill shown for a file you can't edit now: Move and Hand, and a badge saying why ("View
|
||||
/// only", or "Publishing…" until the file is on the server).
|
||||
fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// The editing tools are off: the tool falls back to moving around.
|
||||
if !matches!(app.session.tool.tool, ToolId::Move | ToolId::Hand) {
|
||||
app.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
let label = if why == figcraft_engine::live::PUBLISHING { "Publishing…" } else { "View only" };
|
||||
let cell = 32.0;
|
||||
let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text);
|
||||
let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;
|
||||
let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + badge_w + 8.0;
|
||||
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let active = app.session.tool.tool == tool;
|
||||
let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tool.label(), pretty_shortcut(tool.shortcut())));
|
||||
if active {
|
||||
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.run("tool.set", json!({"tool": tool.id()}));
|
||||
}
|
||||
x += cell + 4.0;
|
||||
}
|
||||
divider(ui, &t, x, cy);
|
||||
let badge = Rect::from_min_size(pos2(x + 12.0, cy - 13.0), vec2(badge_w, 26.0));
|
||||
let resp = ui.interact(badge, ui.id().with("view_only"), Sense::hover()).on_hover_text(why);
|
||||
a11y::status(&resp, label, why);
|
||||
ui.painter().rect_filled(badge, CornerRadius::same(13), t.field);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(badge.min.x + 12.0 + 7.0, cy), vec2(14.0, 14.0)), Icon::Eye, t.icon_secondary);
|
||||
ui.painter().galley(pos2(badge.min.x + 12.0 + 14.0 + 6.0, cy - text.size().y / 2.0), text, t.text);
|
||||
}
|
||||
|
||||
/// The pill shown in vector edit mode: the edit tools and Done.
|
||||
fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the `plugin` and `collab` crates from #101 and #103) · **Target:** Figma Design
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design
|
||||
|
||||
How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped
|
||||
data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of
|
||||
|
|
@ -29,8 +29,8 @@ replaced.
|
|||
| L5 | `figcraft-engine` | ~39.3k | Sessions, undo history (`Arc` snapshots), the command registry (`cmd/*`, `spec!`), pointer tools and vector edit mode, snapping and measuring, components sync, libraries, prototype runtime, Dev Mode code generation, the menu catalog (`catalog.rs`) and the keymap (`keymap.rs`), a panic guard |
|
||||
| L6 | `figcraft-ui-egui` | ~31.9k | The UI3-style frontend: canvas (render worker), toolbar, layers, Design/Prototype/Inspect panels, colour picker, menus, dialogs, presenter, comments, accessibility (AccessKit), the control channel server |
|
||||
| L6 | `figcraft-mcp` | ~2.4k | MCP server (JSON-RPC over stdio), headless or bridged to a running app |
|
||||
| L6 | `figcraft-collab-net` | ~2.4k | Live sessions over the network: the WebSocket transport (tungstenite, a reader and a writer thread per connection), the session host the app embeds (runs the collaboration core's server), invite links, presence relay and limits; the only crate allowed networking dependencies |
|
||||
| apps | `figcraft`, `figcraft-cli`, `figcraft-web` | ~1.9k | Desktop app (eframe + wgpu), command line, browser build (WASM, WebGPU/WebGL2) |
|
||||
| L6 | `figcraft-collab-net` | ~3.0k | Live sessions over the network: the WebSocket transport (tungstenite, a reader and a writer thread per connection), the hub that runs a collaboration core server per open file for the session host the app embeds and for `figcraft-server` (behind a `Backend`), the joining and managing clients (publishing), invite links, presence relay and limits; the browser client; the only crate allowed networking dependencies |
|
||||
| apps | `figcraft`, `figcraft-cli`, `figcraft-web`, `figcraft-server` | ~3.2k | Desktop app (eframe + wgpu), command line, browser build (WASM, WebGPU/WebGL2), collaboration server (files, share links, persistence; [`server.md`](server.md)) |
|
||||
| plugins | `plugins/` (own workspace) | | Rust plugin SDK and two example plugins, built for wasm32 by `cargo xtask plugins` ([`plugins.md`](plugins.md)) |
|
||||
| tools | `xtask` | | `ci`, `layers`, `assets`, `parity`, `version`, `icon`, `wasm`, `plugins`, `bench` |
|
||||
|
||||
|
|
@ -80,5 +80,6 @@ input.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app |
|
||||
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
|
||||
| 2026-10-10 | major | Created from the code as of PR #99 |
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ commands, serve MCP and measure performance. Build it with
|
|||
|
||||
Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input.
|
||||
|
||||
The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p
|
||||
figcraft-server`): it keeps files with share links and serves their live sessions, and its
|
||||
`key`, `files` and `links` subcommands manage them. See [server.md](server.md).
|
||||
|
||||
## Subcommands
|
||||
|
||||
### `info <file> [--json]`
|
||||
|
|
@ -99,7 +103,8 @@ machines.
|
|||
### `mcp [--attach PORT]`
|
||||
|
||||
Serve MCP on stdio: headless by default, or attached to a running app started with
|
||||
`figcraft --control PORT`. See [mcp.md](mcp.md).
|
||||
`figcraft --control PORT`. See [mcp.md](mcp.md). The headless session can host, join and publish
|
||||
live sessions (`collab.*`); the one-shot `run` can't (no network link).
|
||||
|
||||
### `perf [--layers N[,N...]]`
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ handling and offline queueing with reconnect (`crates/collab`, wired into the en
|
|||
session and the transport). **Step 4 is implemented**: the browser build joins sessions a desktop
|
||||
app hosts, and `figcraft://` invite links open the app and join. Styles, variables, collections,
|
||||
modes and comments created in a session take ids from their creator's id space, and others' text
|
||||
carets show on the canvas. The rest is the plan the next
|
||||
implementation PRs follow and update; sections say what is built.
|
||||
carets show on the canvas. **Step 5 is implemented**: the
|
||||
standalone `figcraft-server` (`apps/figcraft-server`, running it: [`server.md`](server.md)) keeps
|
||||
many files with persistence and compaction, and share links that let people edit or only view;
|
||||
the app publishes to it. The rest is the plan the next implementation PRs follow and update;
|
||||
sections say what is built.
|
||||
|
||||
Goal: several people edit the same `.figcraft` file at the same time and see each other's edits,
|
||||
cursors and selections live, on the desktop app and in the browser, without a central cloud
|
||||
|
|
@ -136,7 +139,7 @@ As built:
|
|||
hash, the encoded bytes as base64). The page order is the pages' positions.
|
||||
- **Next**: this file's published library (it embeds a snapshot document; it moves with an asset
|
||||
channel, together with images, which then stop travelling inside batches); the version history
|
||||
(kept beside the document, not in it) with the standalone server; finer entries for modes,
|
||||
(kept beside the document, not in it) on the standalone server; finer entries for modes,
|
||||
per-mode variable values and comment replies (see Identifiers).
|
||||
- **Changes** (`Change`): `Create {id, kind, parent, position, props}` (only the properties that
|
||||
differ from a new node of that kind), `Set {id, prop, value}`, `Delete {id}` (the subtree; a
|
||||
|
|
@ -152,8 +155,9 @@ As built:
|
|||
|
||||
## Messages
|
||||
|
||||
*Built* (`figcraft_collab::message`): the types below and both encodings. The transport and the
|
||||
presence UI come in step 2.
|
||||
*Built* (`figcraft_collab::message`): the types below and both encodings. Protocol version 2 (the
|
||||
`Hello`'s `protocol`): the welcome says what the client may do, and a connection can manage a
|
||||
server's files instead of joining one; a client of another version is refused with the reason.
|
||||
|
||||
**Encoding (decided):** a binary message is a 1-byte wire version followed by **postcard** (serde),
|
||||
which is compact and needs no schema files. Property values inside are compact JSON text: the
|
||||
|
|
@ -174,17 +178,20 @@ Client → server:
|
|||
| `Presence {cursor?, selection?, viewport?, editing_text? {node, caret, selection?}}` | Ephemeral; throttled to ~30 Hz, latest wins. |
|
||||
| `Ack {seq}` | Highest sequence applied (lets the server trim per-client buffers). |
|
||||
| `Ping` | Keep-alive. |
|
||||
| `Manage {protocol, key, client_version}` | Instead of `Hello`: manage a server's files with its key. |
|
||||
| `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. |
|
||||
|
||||
Server → client:
|
||||
|
||||
| Message | Meaning |
|
||||
|---|---|
|
||||
| `Welcome {session, client_id, seq, catch_up: Snapshot or Delta, processed, peers}` | Join accepted: the full document, or for a reconnecting client the batches it missed; what the server did with that client's batches (`processed {last_batch, refused}`). |
|
||||
| `Welcome {session, client_id, seq, catch_up: Snapshot or Delta, processed, peers, access}` | Join accepted: the full document, or for a reconnecting client the batches it missed; what the server did with that client's batches (`processed {last_batch, refused}`); `access` is `edit` or `view` (a view link: every batch is refused). |
|
||||
| `Changes {seq, author, batch_id, changes}` | Accepted changes, in order, to everyone (the author gets its own back as confirmation). |
|
||||
| `Reject {batch_id, reason, fixes: [Change]}` | A batch (or part of it) was invalid: the current values to roll back to. |
|
||||
| `Presence {client_id, …}` / `PeerJoined` / `PeerLeft` | Other people's cursors, selections and arrivals. |
|
||||
| `Refused {reason}` | The connection is refused (protocol version, token, session full, a message out of place). |
|
||||
| `Pong` | Keep-alive. |
|
||||
| `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. |
|
||||
|
||||
`Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a
|
||||
node deletes its subtree; ids are never allocated twice), plus the document-level `SetDoc`, `Put`
|
||||
|
|
@ -352,11 +359,12 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
|||
`ws://`/`wss://` URL). Others on the same network (or through a forwarded port / VPN such as a
|
||||
mesh VPN) join with "File ▸ Join live session" or by opening the link. When the host leaves, the
|
||||
session ends; everyone keeps a local copy.
|
||||
- **Self-hosted server** (next step): the same server as a `figcraft-server` binary that keeps many
|
||||
files, persists them (a snapshot plus an append-only log of accepted batches, compacted into new
|
||||
snapshots), and survives restarts. TLS comes from a reverse proxy (`wss://`).
|
||||
- **Auth**: an unguessable per-session token in the invite link (first step); accounts and per-file
|
||||
permissions (view, edit) come with the standalone server.
|
||||
- **Self-hosted server**: the same server as a `figcraft-server` binary that keeps many files,
|
||||
persists them (a snapshot plus an append-only log of accepted batches, compacted into new
|
||||
snapshots), and survives restarts. TLS comes from a reverse proxy (`wss://`). *Built*, below.
|
||||
- **Auth**: an unguessable per-session token in the invite link (hosting from the app); per-file
|
||||
share links that let people edit or only view, and a server key that manages files (the
|
||||
standalone server). User accounts come later.
|
||||
|
||||
*Built* (hosting from the app and joining):
|
||||
|
||||
|
|
@ -426,8 +434,56 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
|||
page refuses other hosts up front with a message instead of failing obscurely. To join from
|
||||
other machines, serve the web build over `http://` on a trusted network (locally:
|
||||
`python3 -m http.server 8767` in the built site, then `http://localhost:8767/` or the machine's
|
||||
LAN address); `wss://` comes with the standalone server behind TLS. Some browsers also ask
|
||||
before a public site may reach private network addresses.
|
||||
LAN address), or use `wss://` links of a `figcraft-server` behind a TLS reverse proxy (the
|
||||
browser opens them; see *Built (the standalone server)*). Some browsers also ask before a public
|
||||
site may reach private network addresses.
|
||||
|
||||
*Built* (the standalone server, `apps/figcraft-server`; running it: [`server.md`](server.md)):
|
||||
|
||||
- **One hub for both hosts.** The app's session host and the server run the same hub
|
||||
(`crates/collab-net/src/hub.rs`): the listener, a reader and a writer thread per connection,
|
||||
and one thread owning a core `Server` per open file. What differs sits behind a `Backend`: which
|
||||
hellos it admits and with what access, where a file that isn't open comes from, what it keeps
|
||||
of the changes, and the managing connections. The app's backend has one file and its session
|
||||
token; the server's is its data directory. Hellos carry the token to the backend, which checks
|
||||
it; the sessions themselves keep no secret.
|
||||
- **Files and share links.** Each file has links, each with an access (`edit` or `view`), a label
|
||||
and a 128-bit token stored only as its SHA-256 hash (compared in constant time). A hello names
|
||||
the file and a link's token; the server joins it with that link's access (`Server::join_as`). A
|
||||
viewer gets every change and everyone's presence and sends its own presence; each batch it
|
||||
sends is refused (`Reject`, "you can only view this file") and rolled back. Revoking a link
|
||||
(or removing a file) disconnects whoever joined with it, with the reason; the link no longer
|
||||
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.
|
||||
- **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
|
||||
128 bits in the data directory. The server's command line (`figcraft-server files|links|key`)
|
||||
uses the same requests when a server is running on the directory, else it changes the directory
|
||||
itself (one process holds it at a time: an OS file lock).
|
||||
- **Publishing from the app.** `collab.publish {server, name?}` (Share ▸ Publish to server…)
|
||||
uploads the open document over a managing connection, then joins the new file with its edit
|
||||
link on the same connection thread; the tab becomes the session's synced document (read-only
|
||||
until the welcome arrives, its undo history per person after) and `collab.peers` reports
|
||||
`published {server, file, name, edit, view}`.
|
||||
- **Persistence.** Each open file's server keeps a journal of what changes its lasting state
|
||||
(`Server::journaling`: client ids handed out, accepted batches, refused batch ids); the store
|
||||
appends it to the file's log and syncs it once per burst of messages, before the hub sends what
|
||||
the server said, so a confirmed edit is never lost to a crash. A restart restores the last
|
||||
snapshot (`Server::restore`) and replays the log (`Server::replay`; entries already in the
|
||||
snapshot change nothing, a log that stops making sense is cut there). Compaction writes the
|
||||
next generation's snapshot (`Server::state`) and an empty log of that generation (a log of an
|
||||
older generation is from a compaction cut short and is ignored), after 1,000 entries or 16 MiB
|
||||
of log, when everyone leaves (the file is then closed) and when the server stops. Snapshots and
|
||||
logs carry CRC-32 checksums; writes go to a temporary file that is flushed and renamed.
|
||||
Damaged files are skipped and reported at startup, never deleted; a damaged log end is dropped.
|
||||
- **Restarts.** A stopping server closes connections with code 1012 (service restart), which
|
||||
clients take as a dropped connection: they reconnect with their usual backoff and resume as the
|
||||
same clients (the session id, client ids and log come back with the file), sending what they
|
||||
made meanwhile. The app's session ending (code 1001) stays final.
|
||||
|
||||
*Built* (opening invite links):
|
||||
|
||||
|
|
@ -487,7 +543,8 @@ Decisions, as built:
|
|||
- **Session state.** `Server::state` returns what a server needs to carry on after a restart
|
||||
(`SessionState`: snapshot, sequence number, session id, client-id counter, the clients that can
|
||||
reconnect and what was done with their batches, deleted ids, the log), as one serde value; the
|
||||
standalone server will store it.
|
||||
standalone server stores it as each file's snapshot, with a journal of the changes since
|
||||
(`Server::journaling`, `Server::replay`).
|
||||
|
||||
Tests: random sessions where clients drop and come back mid-session converge, with the default log
|
||||
and with logs short enough that long absences fall back to snapshots; targeted tests cover a lost
|
||||
|
|
@ -496,9 +553,9 @@ saved state.
|
|||
|
||||
## Agents, MCP and the CLI
|
||||
|
||||
Everything stays a command: `collab.host`, `collab.join`, `collab.leave`, `collab.peers`,
|
||||
`collab.follow` are ordinary commands, so the CLI, the control channel and MCP can host and join
|
||||
sessions; an agent can be a participant with its own cursor.
|
||||
Everything stays a command: `collab.host`, `collab.join`, `collab.publish`, `collab.leave`,
|
||||
`collab.peers`, `collab.follow` are ordinary commands, so the CLI, the control channel and MCP can
|
||||
host, publish and join sessions; an agent can be a participant with its own cursor.
|
||||
|
||||
*Built*: the desktop app (control channel) and the MCP server's headless session (`figcraft-cli
|
||||
mcp`) both have the network link installed; `collab.join` returns at once with status
|
||||
|
|
@ -522,12 +579,12 @@ chooses to share on the network; the default is the invite link with a random to
|
|||
popover says who can join.
|
||||
- **The token.** 128 bits from the operating system's random number generator, URL-safe base64,
|
||||
new for every session (stopping and starting again invalidates old links). It is compared in
|
||||
constant time (the core's `Server`). It is in the invite link by design, so whoever holds the link
|
||||
constant time (the hub's backend). It is in the invite link by design, so whoever holds the link
|
||||
can join until the session stops: share it like a password. It never travels in the WebSocket
|
||||
request (the path is `/`), so proxies don't log it; FigCraft never logs it (logs name addresses
|
||||
and ports only), and errors about links never echo the link. Traffic is not encrypted (`ws://`):
|
||||
on a shared network, others on the path could read presence and the token. TLS (`wss://`) comes
|
||||
with the standalone server behind a reverse proxy; until then, use a trusted LAN or a VPN.
|
||||
on a shared network, others on the path could read presence and the token. Use a trusted LAN
|
||||
or a VPN; TLS (`wss://`) comes from a reverse proxy in front of a `figcraft-server`.
|
||||
- **Limits.** At most 64 open sockets (joined or not) and the core's 256 people; a connection must
|
||||
finish the WebSocket handshake and say hello within 10 s, and one silent for 45 s is dropped
|
||||
(clients ping every 15 s); client messages are at most 16 MiB (what the host sends keeps the
|
||||
|
|
@ -536,8 +593,32 @@ chooses to share on the network; the default is the invite link with a random to
|
|||
outgoing queue fills (it can't keep up); names are cut to 64 characters without control
|
||||
characters; presence numbers must be finite and selections are cut to 10,000 ids.
|
||||
- **Malformed input.** Anything that isn't a binary message of the current wire version that
|
||||
decodes in full is answered with `Refused` and a close; the first message must be `Hello`, for
|
||||
this file. The invite parser and the host's reader are fuzzed (`crates/collab-net`).
|
||||
decodes in full is answered with `Refused` and a close; the first message must be `Hello` (for
|
||||
this file) or, on a server, `Manage`. The invite parser and the host's reader are fuzzed
|
||||
(`crates/collab-net`), and so are the server's readers of its own files and its port
|
||||
(`apps/figcraft-server`).
|
||||
|
||||
*Built* (the standalone server), and what it adds to the threat model:
|
||||
|
||||
- **Who can connect.** Whoever can reach the server's port (127.0.0.1 by default; the operator
|
||||
chooses an interface or a reverse proxy). Joining a file needs one of its link tokens; managing
|
||||
files needs the server key. There are no accounts: a link is a bearer secret for one file and
|
||||
one access level, so share it like a password and revoke it when it leaks; the server key is
|
||||
the administrator's and should stay with trusted people.
|
||||
- **Secrets at rest.** Link tokens are kept only as SHA-256 hashes, so a copy of the data
|
||||
directory can't be used to join (the documents themselves are in it, unencrypted). The server
|
||||
key is plain text in the data directory (owner-only on Unix): reading the directory already
|
||||
gives every file. A lost link can't be shown again; revoke it and make a new one.
|
||||
- **View access is enforced by the server**, not by the app: a viewer's batches are refused
|
||||
whatever client sends them; the app's read-only state is for clarity.
|
||||
- **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
|
||||
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,
|
||||
encryption at rest.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
|
|
@ -558,4 +639,8 @@ chooses to share on the network; the default is the invite link with a random to
|
|||
carets on the canvas.
|
||||
4. **Browser client** (WebSocket through `web-sys`). **Done** (the web build joins
|
||||
desktop-hosted sessions; `figcraft://` links open the desktop app and join).
|
||||
5. **Standalone `figcraft-server`** with persistence, compaction and accounts.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~12% (estimated):** comments and version
|
||||
history work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
server ordering, client reconciliation, convergence tests), and live sessions (preview) let
|
||||
several apps co-edit a file hosted by one desktop app, joined from the desktop app or the browser,
|
||||
with others' cursors, selections and text carets shown live ([`collab-protocol.md`](collab-protocol.md)). Remaining: 55–110 h
|
||||
several apps co-edit a file hosted by one desktop app or kept on a self-hosted `figcraft-server`
|
||||
with share links to edit or view, joined from the desktop app or the browser, with others'
|
||||
cursors, selections and text carets shown live ([`collab-protocol.md`](collab-protocol.md), [`server.md`](server.md)). Remaining: 55–110 h
|
||||
(multiplayer 45–90 h, shared libraries 10–20 h), plus branching 8–15 h.
|
||||
|
||||
| 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. Left: standalone server with TLS | 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…). Left: TLS in the desktop app (browsers use `wss://` through a reverse proxy), packaging the server | 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 | no | 8–17 |
|
||||
| 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 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file | — |
|
||||
| Branching and merging | yes (paid plans) | no | 8–15 |
|
||||
|
|
@ -25,6 +26,7 @@ with others' cursors, selections and text carets shown live ([`collab-protocol.m
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
|
||||
| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
|
||||
|
|
|
|||
|
|
@ -90,10 +90,12 @@ pointer press, so the focus ring shows) and the name of the `control` with the k
|
|||
screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or
|
||||
the system's accessibility setting turns the interface's animations off. `live` is the live
|
||||
session as the window shows it: `status`, `role`, the number of `peers`, who you `follow`, the
|
||||
peers whose cursors are drawn on the canvas (`cursorsShown`) and whether the Join dialog is open
|
||||
(`joinDialog`; Escape closes it); the session itself is `engine.execute {command: "collab.peers"}`
|
||||
(hosting and joining are `collab.host` and `collab.join`, opening invite links
|
||||
`collab.registerLinks`, see [mcp.md](mcp.md#live-sessions)). The app also joins the invite link it
|
||||
peers whose cursors are drawn on the canvas (`cursorsShown`), whether the Join and Publish to
|
||||
server dialogs are open (`joinDialog`, `publishDialog`; Escape closes them) and why the active
|
||||
document can't be edited now (`readOnly`: the "View only" or "Publishing" reason, else null); the
|
||||
session itself is `engine.execute {command: "collab.peers"}` (hosting, joining and publishing are
|
||||
`collab.host`, `collab.join` and `collab.publish`, opening invite links `collab.registerLinks`,
|
||||
see [mcp.md](mcp.md#live-sessions)). The app also joins the invite link it
|
||||
was started with (`figcraft "figcraft://join?…"`).
|
||||
|
||||
### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}`
|
||||
|
|
|
|||
24
docs/gaps.md
24
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **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
|
||||
|
|
@ -15,7 +15,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| # | Gap | Kind | Hours | Doc |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `.fig` files can't be opened (**beta blocker**) | File format | 30–60 | [file-format-parity](file-format-parity.md) |
|
||||
| 2 | No real-time multiplayer or sharing (**beta blocker**) | Feature | 45–90 | [collaboration-parity](collaboration-parity.md) |
|
||||
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 45–90 | [collaboration-parity](collaboration-parity.md) |
|
||||
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
|
||||
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
|
||||
| 5 | No localization layer; English only | Localization | 60–100 | [localization-parity](localization-parity.md) |
|
||||
|
|
@ -54,21 +54,26 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
model), with a corpus of synthetic `.fig` files saved from the installed Figma. Export to `.fig`
|
||||
is a further 15–30 h and lower priority.
|
||||
|
||||
### 2. No real-time multiplayer or sharing
|
||||
- **Missing:** co-editing, live cursors and selections, observation mode, cursor chat, spotlight,
|
||||
audio, share links and permissions. Comments and versions are local to the file.
|
||||
### 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.
|
||||
- **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
|
||||
app, invite links that open the app, document sync between apps, live cursors, selections,
|
||||
text carets, avatars and follow, and the browser build joins) with per-person undo, refused
|
||||
edits rolled back with a reason, reconnects that resume and send what was made offline, and
|
||||
styles, variables and comments that several people create at once all kept; but sessions are
|
||||
plain `ws://` (no TLS, so an https page can only join on the same computer), and there are no
|
||||
share links or permissions (`docs/collab-protocol.md`).
|
||||
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`).
|
||||
- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work
|
||||
without it.
|
||||
- **Estimate:** 45–90 h: sharing and permissions and a standalone server with TLS.
|
||||
- **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).
|
||||
|
||||
### 3. Nothing checked against real Figma files
|
||||
- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and
|
||||
|
|
@ -225,6 +230,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
|
||||
| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |
|
||||
|
|
|
|||
16
docs/mcp.md
16
docs/mcp.md
|
|
@ -529,12 +529,24 @@ joined file opens as a new document).
|
|||
`collab.peers` does, with `link` (`figcraft://join?host=…&file=…&token=…`) and `url`
|
||||
(`ws://…/?file=…&token=…`): both carry the session's secret, so share them only with people you
|
||||
invite. `collab.stop {}` ends it.
|
||||
- `collab.join {link}` (either form) returns at once with `status: "connecting"`; poll
|
||||
- `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, an unreachable host). `collab.leave {}` leaves.
|
||||
session, a revoked link, an unreachable host). `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
|
||||
file's name (default: the document's). It returns at once with `status: "connecting"`; the
|
||||
document is read-only until the upload is done, then `collab.peers` has `published: {server,
|
||||
file, name, edit, view}` with the file's first edit link and view link (they carry their
|
||||
tokens: share them like passwords). Someone joining with the view link gets `access: "view"`:
|
||||
every edit on the synced document is refused with "View only: …" (the server refuses them too).
|
||||
- `collab.peers {}` → `{available, canHost (false in the web build), status
|
||||
(off|connecting|live|reconnecting|failed), reason?,
|
||||
attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network,
|
||||
access (edit|view), readOnly (the active document can't be edited now: view only, or being
|
||||
published), published (a file you published: {server, file, name, edit, view}),
|
||||
follow, syncing (the document arrived), document (the index of the synced open document),
|
||||
editing (the text layer you are typing in: {node, layer, caret, selection}, or null), peers: [{id,
|
||||
name, color, editing (the text layer they are typing in, same shape, or null), presence: {cursor:
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ is in the workspace: `packaging/env.sh` sets `BINS`/`HAS_CLI` from that, and `pa
|
|||
`figcraft --version` prints the version and, in CI builds, the commit (`FIGCRAFT_BUILD_SHA`,
|
||||
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).
|
||||
|
||||
**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
|
||||
`CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail rather than ship without them.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: 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**: a standalone server with TLS and sharing on top of the live sessions (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, and joining from the browser landed), per [`collab-protocol.md`](collab-protocol.md): 45–90 h.
|
||||
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.
|
||||
|
||||
## Alpha gate
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ builders' side-by-side checks with synthetic files; nothing has been run on real
|
|||
| Build and use a design system: components, variants, properties, variables and modes, libraries | yes | `components_scale_tests.rs` (300 components, 2,000 instances), `library_tests.rs`, `library_variables_tests.rs`; libraries are local files, not shared | — |
|
||||
| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | — |
|
||||
| Hand off to developers: Dev Mode inspect, code, measurements, asset export, ready for dev | yes | `devinspect_tests.rs`, `component_codegen_tests.rs`, `devcompare_tests.rs`, `annotations_tests.rs` | — |
|
||||
| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app, joined from the app or the browser) but no share links or per-person undo yet ([collaboration-parity](collaboration-parity.md)) | beta gap: 45–90 |
|
||||
| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app or a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, undo per person); no accounts or teams ([collaboration-parity](collaboration-parity.md)) | beta gap: 45–90 |
|
||||
| Continue existing Figma work (open `.fig`) | no (beta gap) | no `.fig` reader; REST JSON import works for files fetched through Figma's API ([file-format-parity](file-format-parity.md)) | beta gap: 30–60 |
|
||||
|
||||
**Passes, so FigCraft stays alpha:** every daily design workflow works end to end on FigCraft's
|
||||
|
|
@ -55,7 +55,7 @@ blockers.
|
|||
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 |
|
||||
| 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) | standalone server with TLS, sharing | 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) | accounts, teams, TLS in the desktop app, packaging the server | 45–90 |
|
||||
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
|
||||
| M11 Plugins | runtime, API, menu, manager and SDK done (#101) | run-time plugin UI, settings storage, distribution, widgets; Figma plugin compatibility is an owner decision | 25–55 |
|
||||
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
|
||||
|
|
@ -71,6 +71,7 @@ blockers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
|
||||
| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
# figcraft-server
|
||||
|
||||
`figcraft-server` keeps FigCraft files for a team and serves their live sessions. A file published
|
||||
to it stays there when everyone has left; people open it with a **share link** that lets them
|
||||
**edit** it or only **view** it. Edits are kept as a snapshot plus a log of what changed, so a
|
||||
restart (or a crash) loses nothing that was confirmed, and apps reconnect where they were. It is
|
||||
headless, native only (no web build), and runs on the same platforms as the desktop app.
|
||||
|
||||
There are no user accounts yet: a share link is the access to one file, and the **server key**
|
||||
manages files (publishing, links, removal). Accounts and per-person permissions come later.
|
||||
|
||||
The protocol and the design behind it are in [`collab-protocol.md`](collab-protocol.md).
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
cargo build --release -p figcraft-server
|
||||
target/release/figcraft-server --listen 0.0.0.0:47900 --data /srv/figcraft
|
||||
```
|
||||
|
||||
- `--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).
|
||||
- `--data` is the data directory (default `./figcraft-data`), made if missing. One server at a
|
||||
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.
|
||||
|
||||
## Publishing a file
|
||||
|
||||
```sh
|
||||
figcraft-server --data /srv/figcraft key
|
||||
```
|
||||
|
||||
prints the address to publish to, `ws://<host>:47900/?key=<server key>`. In the desktop app,
|
||||
**Share ▸ Publish to server…** (or **File ▸ Publish to server…**) asks for that address and the
|
||||
file's name. The open file is uploaded as a new file and the tab keeps syncing through the
|
||||
server; until the upload is done the tab is read-only ("Publishing…" in the toolbar). The Share
|
||||
popover then shows an **edit link** and a **view link** to copy. Agents do the same with
|
||||
`collab.publish {server, name?}` ([`mcp.md`](mcp.md#live-sessions)).
|
||||
|
||||
Whoever has the server key can publish files and manage every file's links: give it only to
|
||||
people you trust.
|
||||
|
||||
## Joining
|
||||
|
||||
Share links look like the desktop app's invite links and join the same way (**File ▸ Join live
|
||||
session…**, opening the link, or `collab.join {link}`):
|
||||
|
||||
- `figcraft://join?host=<host:port>&file=<id>&token=<token>` for a plain `ws://` server;
|
||||
- `figcraft://join?server=wss://figcraft.example.com/&file=<id>&token=<token>` for a server behind
|
||||
a TLS reverse proxy or under a path;
|
||||
- `ws://<host:port>/?file=<id>&token=<token>` or `wss://…` (the same link as a WebSocket URL).
|
||||
|
||||
The file opens as a new tab. With a **view link** you see everyone's changes and presence, and
|
||||
others see your cursor and selection, but you can't change anything: the toolbar shows a **View
|
||||
only** badge with only the Move and Hand tools, edits are refused with a message (and the server
|
||||
refuses them too). **Leave session** keeps the file open as your own copy, editable again.
|
||||
|
||||
## Managing files and links
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `figcraft-server key [--rotate]` | The address to publish to, with the key; `--rotate` makes a new key (the server must be stopped) |
|
||||
| `figcraft-server files [list]` | The files: id, name, people in it now, last change, size |
|
||||
| `figcraft-server files remove FILE` | Remove a file and its links for good (whoever is in it is disconnected) |
|
||||
| `figcraft-server links list FILE` | A file's links: id, access, created, label |
|
||||
| `figcraft-server links create FILE --access edit\|view [--label TEXT]` | A new link, printed once |
|
||||
| `figcraft-server links revoke FILE LINK` | Revoke a link: it no longer joins, and whoever joined with it is disconnected |
|
||||
|
||||
Each takes `--data` (or `--config`). When a server is running on the directory, the command asks
|
||||
it over the same port with the server key (so a revoked link's users are disconnected at once);
|
||||
otherwise it changes the directory itself. A new link is shown only when it is made: the server
|
||||
keeps only a hash of its token, so a lost link can't be recovered (revoke it and make another).
|
||||
Publishing makes an edit link and a view link labelled `Published (edit)` and `Published (view)`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every flag is also a key of a TOML file given with `--config server.toml` (flags win; unknown keys
|
||||
are errors):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `listen` | `127.0.0.1:47900` | Where to listen |
|
||||
| `data` | `figcraft-data` | The data directory |
|
||||
| `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) |
|
||||
| `max_files` | 1000 | Most files kept |
|
||||
| `max_file_mb` | 64 | Largest file (its snapshot), in MiB; a welcome carries the whole file, and messages are at most 64 MiB |
|
||||
| `max_links` | 100 | Most share links per file |
|
||||
| `max_connections` | 512 | Most open connections |
|
||||
| `max_people` | 256 | Most people in one file at once |
|
||||
| `compact_after` | 1000 | Compact a file's log into a new snapshot after this many entries… |
|
||||
| `compact_mb` | 16 | …or once its log is this large (MiB) |
|
||||
|
||||
With `listen` on every interface (`0.0.0.0`) and no `public_url`, links name this machine's
|
||||
network address.
|
||||
|
||||
## The data directory
|
||||
|
||||
```text
|
||||
<data>/LOCK held while the server (or a command) uses the directory
|
||||
<data>/server.json where the running server listens (read by the commands)
|
||||
<data>/server.key the server key
|
||||
<data>/files/<id>/meta.json the file's name, when it was created and last changed
|
||||
<data>/files/<id>/links.json its links: id, access, label, created, SHA-256 of the token
|
||||
<data>/files/<id>/snapshot.bin its state at the last compaction (with a checksum)
|
||||
<data>/files/<id>/log.bin what changed since, one checksummed record per change
|
||||
```
|
||||
|
||||
- **Writes are atomic**: a snapshot, a description or a link list is written to a temporary file
|
||||
beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced
|
||||
before the server tells anyone about the change.
|
||||
- **Compaction** writes the file's state as the next snapshot and starts an empty log, after
|
||||
`compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server
|
||||
stops. A log left over from a compaction cut short is recognised and ignored (the snapshot holds
|
||||
it all).
|
||||
- **Damage is skipped and reported, never deleted.** At startup a folder whose description, links
|
||||
or snapshot can't be read is left out and logged; a log that ends in a cut-short or damaged
|
||||
record loses only that end (logged when the file opens). Leftover temporary files are removed.
|
||||
- The server key is stored as plain text (on Unix readable by the owner only): whoever can read
|
||||
the data directory can read every file anyway. Link tokens are stored only as hashes.
|
||||
|
||||
## Backups
|
||||
|
||||
Copy the data directory while the server is stopped, or take an atomic snapshot of the
|
||||
filesystem it lives on (ZFS, Btrfs, LVM, a VSS shadow copy). A plain copy of a running server's
|
||||
directory can catch a file between its snapshot and its log; it restores, but may lose that
|
||||
file's changes since its last compaction. To restore, stop the server, put the directory back and
|
||||
start it: apps that were in a file catch up with a fresh copy (their unsent edits are kept when
|
||||
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.
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
## Security
|
||||
|
||||
- **Access** is a share link (128-bit random token) per file and access level, or the server key
|
||||
(128-bit) for managing files. Tokens are stored as SHA-256 hashes and every comparison runs in
|
||||
constant time; revoking a link disconnects whoever joined with it. A view link's batches are
|
||||
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.
|
||||
- **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
|
||||
defensively, and fuzzed in the tests.
|
||||
- **Not there yet**: user accounts, per-person permissions, rate limits per address, audit logs.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (live boolean editing and the vector tail: 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 (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: 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/TIFF/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
|
||||
| 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching; Open recent | 4–8 |
|
||||
| 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. Left: per-person undo, offline resume, cursor chat, share links and permissions ([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, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
|
||||
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
|
||||
| 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 |
|
||||
| 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 |
|
||||
|
|
@ -125,7 +125,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| Features (depth) | 35 | 57% | 71–135 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
|
||||
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
|
||||
| File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
|
||||
| Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
| Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
|
||||
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
|
||||
| Hardware | 3 | 50% | 15–30 | Trackpad pinch and HiDPI yes; no pen pressure, no GPU canvas, no P3 on screen, one window | [hardware-parity](hardware-parity.md) |
|
||||
|
|
@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
|
||||
| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue