M9: live sessions in the browser; join links and dialog polish (#106)
- Browser client (step 4): the web build joins sessions a desktop app hosts
through the browser's WebSocket API (web-sys, binary frames, the same
message encoding). Its connection logic is a target-independent state
machine (collab-net driver.rs): hello on open, changes and presence held
until the welcome, throttled presence, keep-alive pings, a silent host
counted as dropped, reconnect with backoff, final refusals. Tested natively
against a scripted socket and a real host over loopback. Hosting stays
desktop-only (LiveLink::can_host; collab.peers canHost). https pages are
told up front that they can only reach ws:// sessions on this computer.
- Shared join logic (join.rs: GuestConfig, outcomes, taking in host messages)
and transport limits (limits.rs) used by the native guest and the browser.
- Invite links: `figcraft figcraft://join?...` joins on start (a bad link
opens the Join dialog with it); file:// arguments are paths. Windows
registers a per-user figcraft:// opener (HKCU\Software\Classes\figcraft)
behind a Registry trait (tests use a fake): collab.registerLinks {on?} and a
Preferences checkbox, default on for installed builds, never for builds
from source. macOS Info.plist declares CFBundleURLTypes; the Linux desktop
entry declares x-scheme-handler/figcraft and passes %U.
- Join dialog: Escape closes it and no longer reaches the canvas (selection
kept); its field has the keyboard when it opens.
- Docs: collab-protocol (browser client, links, mixed content), mcp,
control-protocol, README, web hosting notes, collaboration wording in the
progress docs (numbers unchanged).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f9b1b7fb15
commit
435415770f
|
|
@ -1453,10 +1453,15 @@ dependencies = [
|
|||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"tungstenite",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5267,6 +5272,17 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ 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). 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 (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).
|
||||
- **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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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-10 · **Change:** minor (merged #101–#103: plugins and the multiplayer core; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged) · **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
|
||||
|
|
@ -59,7 +59,7 @@ the localization layer with Figma's own languages (20–35 h).
|
|||
| Features (depth) | 57% (core editor 67%) | 78–146 | [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 and cursors, preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Collaboration | 12% (core in; live sessions with document sync and cursors, the browser joins, invite links open the app; 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): the browser client and sharing on top of the live sessions (transport, hosting, invite links, document sync, presence, per-user undo and reconnect with offline edits landed): 45–90 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, per-user undo, reconnect with offline edits and the browser client landed): 45–90 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
|
|
@ -152,6 +152,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) |
|
||||
| 2026-10-10 | minor | Added essentials user (~53%) and mainstream practitioner (~43%); ready for real work unchanged at ~46% |
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
//! Differences from the desktop app:
|
||||
//! - no TCP control server (browsers can't listen on sockets);
|
||||
//! - no native file dialogs yet: the session starts with one new document;
|
||||
//! - links open in a new browser tab.
|
||||
//! - links open in a new browser tab;
|
||||
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
|
||||
//! host one. A page served over `https://` can only reach sessions on the same computer
|
||||
//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
|
||||
//! join from other machines.
|
||||
//!
|
||||
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
|
||||
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ pub fn start() {
|
|||
}
|
||||
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
let mut session = Session::new();
|
||||
// Live sessions say they need the desktop app until the browser client lands.
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(|| {}))));
|
||||
// Live sessions: the browser joins sessions a desktop app hosts; whatever arrives
|
||||
// on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
|
||||
/// What the app was started with.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Args {
|
||||
/// `--control <port>`: the JSON-lines control server.
|
||||
pub control: Option<u16>,
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
pub join: Option<String>,
|
||||
}
|
||||
|
||||
/// An invite link rather than a file (`figcraft:` links, in any case).
|
||||
pub fn is_invite(arg: &str) -> bool {
|
||||
arg.get(..9).is_some_and(|s| s.eq_ignore_ascii_case("figcraft:"))
|
||||
}
|
||||
|
||||
/// The path a local `file://` URL names (`%XX` escapes decoded); `None` for anything else.
|
||||
pub fn file_url_path(arg: &str) -> Option<String> {
|
||||
let rest = arg.get(..7).filter(|s| s.eq_ignore_ascii_case("file://")).and(arg.get(7..))?;
|
||||
let path = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !path.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let bytes = path.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if b == b'%' {
|
||||
let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?;
|
||||
out.push(u8::from_str_radix(hex, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(b);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let path = String::from_utf8(out).ok()?;
|
||||
// `file:///C:/…` on Windows.
|
||||
let drive = matches!(path.as_bytes().get(1..3), Some([l, b':']) if l.is_ascii_alphabetic());
|
||||
Some(if cfg!(windows) && drive { path.get(1..).unwrap_or_default().to_string() } else { path })
|
||||
}
|
||||
|
||||
/// Read the arguments after the program name.
|
||||
pub fn parse(args: &[String]) -> Args {
|
||||
let mut out = Args::default();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
out.files.push(path);
|
||||
} else if !a.starts_with("--") {
|
||||
out.files.push(a.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
//! Command-line parsing: files, the control port and invite links.
|
||||
|
||||
use crate::args::{Args, file_url_path, is_invite, parse};
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_links_join_instead_of_opening() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
let a = parse(&args(&[link]));
|
||||
assert_eq!(a.join.as_deref(), Some(link));
|
||||
assert!(a.files.is_empty(), "a link is never opened as a file");
|
||||
// How browsers and shells pass it: any case, a slash after `join`, alongside files.
|
||||
let a = parse(&args(&["design.figcraft", "FIGCRAFT://join/?host=h:1&file=f&token=t"]));
|
||||
assert_eq!(a.files, args(&["design.figcraft"]));
|
||||
assert_eq!(a.join.as_deref(), Some("FIGCRAFT://join/?host=h:1&file=f&token=t"));
|
||||
assert!(is_invite("figcraft:join") && !is_invite("figcraft.svg") && !is_invite("fig") && !is_invite("ü€figcraft:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_urls_are_paths() {
|
||||
// What a Linux desktop passes for `%U`.
|
||||
let a = parse(&args(&["file:///home/ada/My%20Designs/a+b.figcraft", "FILE://localhost/tmp/x.svg"]));
|
||||
assert_eq!(a.files, args(&["/home/ada/My Designs/a+b.figcraft", "/tmp/x.svg"]), "spaces decoded, plus signs kept");
|
||||
assert_eq!(file_url_path("file:///%E2%82%AC.png").as_deref(), Some("/€.png"));
|
||||
let win = file_url_path("file:///C:/Designs/a.figcraft");
|
||||
assert_eq!(win.as_deref(), Some(if cfg!(windows) { "C:/Designs/a.figcraft" } else { "/C:/Designs/a.figcraft" }));
|
||||
for bad in ["file://server/share/a.svg", "file:///broken%2", "file:///bad%zz", "file:///%FF", "file:", "fi", "ü€file:///x"] {
|
||||
assert_eq!(file_url_path(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
//! FigCraft desktop app.
|
||||
//!
|
||||
//! Usage: `figcraft [--control <port>] [files…]`
|
||||
//! Usage: `figcraft [--control <port>] [files…] [figcraft://join?…]` (an invite link joins that
|
||||
//! live session; the app registers itself as the opener of such links: `collab.registerLinks`).
|
||||
//!
|
||||
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
|
||||
|
|
@ -8,9 +9,13 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod args;
|
||||
mod control_server;
|
||||
mod services;
|
||||
|
||||
#[cfg(test)]
|
||||
mod args_tests;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
|
|
@ -82,20 +87,8 @@ fn main() -> eframe::Result {
|
|||
);
|
||||
return Ok(());
|
||||
}
|
||||
let mut port: Option<u16> = std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args.get(i).map(String::as_str) {
|
||||
Some("--control") => {
|
||||
port = args.get(i + 1).and_then(|p| p.parse().ok());
|
||||
i += 1;
|
||||
}
|
||||
Some(f) if !f.starts_with("--") => files.push(f.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let args = args::parse(&args);
|
||||
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
|
||||
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
|
||||
// to lay text out again with them.
|
||||
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
|
||||
|
|
@ -114,7 +107,7 @@ fn main() -> eframe::Result {
|
|||
c.request_repaint();
|
||||
}
|
||||
}))));
|
||||
for f in &files {
|
||||
for f in &args.files {
|
||||
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
|
|
@ -123,11 +116,18 @@ fn main() -> eframe::Result {
|
|||
if let Some(prefs) = svc.prefs.clone() {
|
||||
session.prefs = prefs;
|
||||
}
|
||||
// Invite links open this app (Windows registers per user at run time; installed builds by
|
||||
// default, never a build from source unless asked).
|
||||
session.live.links = figcraft_collab_net::links::system();
|
||||
session.invite_links_startup();
|
||||
if session.docs.is_empty() {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.ui.home = false;
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
}
|
||||
let queue: Arc<Mutex<Vec<ControlRequest>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
if let Some(p) = port {
|
||||
control_server::start(p, queue.clone(), ctx_slot.clone());
|
||||
|
|
|
|||
|
|
@ -13,13 +13,25 @@ figcraft-engine = { workspace = true }
|
|||
figcraft-collab = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# `std::time::Instant` natively, the browser's clock in the web build.
|
||||
web-time = "1"
|
||||
|
||||
# The transport is native-only for now: the browser client (web-sys WebSocket) is a later step.
|
||||
# Native transport: blocking tungstenite with a reader and a writer thread per connection.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
|
||||
getrandom = "0.4"
|
||||
base64 = { workspace = true }
|
||||
|
||||
# The browser client: the page's WebSocket API.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "WebSocket", "BinaryType", "MessageEvent", "CloseEvent"] }
|
||||
|
||||
# Opening invite links: the per-user URL handler registration on Windows.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-registry = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
//! The browser client's connection logic, free of the browser so it runs (and is tested) on any
|
||||
//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's
|
||||
//! threads do — hello on open (resuming as the same client after a drop), presence held until the
|
||||
//! welcome and changes made for an earlier connection dropped (the sync client sends again what
|
||||
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
|
||||
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
|
||||
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
// `std::time::Instant` on native builds; the browser's clock in the web build.
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
/// driver's event methods.
|
||||
pub(crate) trait Socket {
|
||||
/// Start connecting to `url` (one connection at a time: the driver closes the last first).
|
||||
fn open(&mut self, url: &str) -> Result<(), String>;
|
||||
/// Send one binary message; false when the connection can't take it.
|
||||
fn send(&mut self, bytes: &[u8]) -> bool;
|
||||
/// Close the connection normally; it reports nothing more.
|
||||
fn close(&mut self);
|
||||
}
|
||||
|
||||
/// One connection attempt.
|
||||
struct Conn {
|
||||
started: Instant,
|
||||
opened: bool,
|
||||
welcomed: bool,
|
||||
seen: Seen,
|
||||
last_sent: Instant,
|
||||
last_heard: Instant,
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now }
|
||||
}
|
||||
}
|
||||
|
||||
/// A joined (or joining) session over a [`Socket`].
|
||||
pub(crate) struct Driver<S: Socket> {
|
||||
pub(crate) sock: S,
|
||||
cfg: GuestConfig,
|
||||
inbox: Inbox,
|
||||
conn: Option<Conn>,
|
||||
was_live: bool,
|
||||
attempt: u32,
|
||||
/// What a reconnect resumes with.
|
||||
resume: Resuming,
|
||||
/// Your latest presence while not welcomed (sent with the welcome).
|
||||
pending: Option<Presence>,
|
||||
throttle: Throttle<Presence>,
|
||||
reconnect_at: Option<Instant>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl<S: Socket> Driver<S> {
|
||||
/// Start joining (progress arrives in `inbox`).
|
||||
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
|
||||
let mut d = Self {
|
||||
sock,
|
||||
cfg,
|
||||
inbox,
|
||||
conn: None,
|
||||
was_live: false,
|
||||
attempt: 0,
|
||||
resume: Resuming::default(),
|
||||
pending: None,
|
||||
throttle: Throttle::new(PRESENCE_INTERVAL),
|
||||
reconnect_at: None,
|
||||
done: false,
|
||||
};
|
||||
d.inbox.push(Event::Status(Status::Connecting));
|
||||
d.connect(now);
|
||||
d
|
||||
}
|
||||
|
||||
/// The session is over (left, refused, ended or given up): nothing more will happen.
|
||||
pub(crate) fn done(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
fn connect(&mut self, now: Instant) {
|
||||
self.reconnect_at = None;
|
||||
self.conn = Some(Conn::new(now));
|
||||
if let Err(e) = self.sock.open(&self.cfg.invite.endpoint()) {
|
||||
self.end(Outcome::Retry(e), now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection is open: say hello.
|
||||
pub(crate) fn opened(&mut self, now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
c.opened = true;
|
||||
c.last_heard = now;
|
||||
let hello = hello(&self.cfg, &self.resume);
|
||||
if !self.write(&hello, now) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
}
|
||||
|
||||
/// A binary message from the host.
|
||||
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
c.last_heard = now;
|
||||
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
// Single-threaded: the connection counts as welcomed below, before the engine polls.
|
||||
Ok(m) => take(m, &self.cfg, &self.inbox, &mut c.seen, &self.resume, &|| {}),
|
||||
Err(e) => Taken::End(Outcome::Refused(format!("the host sent a message this version can't read ({e})"))),
|
||||
};
|
||||
match taken {
|
||||
Taken::Continue => {}
|
||||
Taken::End(o) => self.end(o, now),
|
||||
Taken::Welcomed => {
|
||||
c.welcomed = true;
|
||||
self.was_live = true;
|
||||
self.attempt = 0;
|
||||
if let Some(p) = self.pending.take() {
|
||||
self.presence(p, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text message: not something the host sends.
|
||||
pub(crate) fn text(&mut self, now: Instant) {
|
||||
if self.conn.is_none() {
|
||||
return;
|
||||
}
|
||||
self.end(Outcome::Refused("the host sent a text message".into()), now);
|
||||
}
|
||||
|
||||
/// The connection closed (`code` and `reason` from the close frame; 1006 when it just broke).
|
||||
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 code == 1006 => Outcome::Retry("the connection dropped".into()),
|
||||
o => o,
|
||||
};
|
||||
self.end(outcome, now);
|
||||
}
|
||||
|
||||
/// Your latest presence (throttled; held until the welcome).
|
||||
pub(crate) fn presence(&mut self, p: Presence, now: Instant) {
|
||||
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
|
||||
self.pending = Some(p);
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.throttle.offer(p, now)
|
||||
&& !self.write(&ClientMsg::Presence(p), now)
|
||||
{
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from the sync client, in order, on a welcomed connection; dropped otherwise (it
|
||||
/// was made for an earlier connection: the sync client sends again what the server didn't get
|
||||
/// once it has caught up with the next welcome).
|
||||
pub(crate) fn send(&mut self, msg: ClientMsg, now: Instant) {
|
||||
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
|
||||
return;
|
||||
}
|
||||
if !self.write(&msg, now) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the session.
|
||||
pub(crate) fn leave(&mut self, now: Instant) {
|
||||
self.end(Outcome::Left, now);
|
||||
}
|
||||
|
||||
/// When [`tick`](Self::tick) should run next.
|
||||
pub(crate) fn deadline(&self) -> Option<Instant> {
|
||||
if self.done {
|
||||
return None;
|
||||
}
|
||||
let l = &self.cfg.limits;
|
||||
let conn = self.conn.as_ref().map(|c| {
|
||||
if c.opened {
|
||||
[Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
|
||||
} else {
|
||||
[Some(c.started + self.cfg.connect_timeout), None, None]
|
||||
}
|
||||
});
|
||||
earliest(conn.into_iter().flatten().chain([self.reconnect_at]))
|
||||
}
|
||||
|
||||
/// Do what's due at `now`: reconnect, send held presence or a ping, give up on a connection
|
||||
/// that doesn't answer.
|
||||
pub(crate) fn tick(&mut self, now: Instant) {
|
||||
if self.done {
|
||||
return;
|
||||
}
|
||||
if self.reconnect_at.is_some_and(|t| t <= now) {
|
||||
return self.connect(now);
|
||||
}
|
||||
let l = self.cfg.limits;
|
||||
let Some(c) = self.conn.as_ref() else { return };
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if now >= last_heard + l.idle_timeout {
|
||||
return self.end(Outcome::Retry("the host stopped answering".into()), now);
|
||||
}
|
||||
let ok = match self.throttle.take_due(now) {
|
||||
Some(p) => self.write(&ClientMsg::Presence(p), now),
|
||||
None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now),
|
||||
None => true,
|
||||
};
|
||||
if !ok {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one message; false when it couldn't go.
|
||||
fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool {
|
||||
if let Some(c) = self.conn.as_mut() {
|
||||
c.last_sent = now;
|
||||
}
|
||||
message::encode(msg).is_ok_and(|b| self.sock.send(&b))
|
||||
}
|
||||
|
||||
/// The connection ended: close it, then stop or plan the reconnect.
|
||||
fn end(&mut self, outcome: Outcome, now: Instant) {
|
||||
if self.conn.take().is_some() {
|
||||
self.sock.close();
|
||||
}
|
||||
self.throttle = Throttle::new(PRESENCE_INTERVAL);
|
||||
match after(outcome, &self.cfg, self.was_live, &mut self.attempt, &self.inbox) {
|
||||
Next::Stop(e) => {
|
||||
if let Some(e) = e {
|
||||
self.inbox.push(e);
|
||||
}
|
||||
self.done = true;
|
||||
}
|
||||
Next::Reconnect(pause) => self.reconnect_at = Some(now + pause),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
//! The browser client's connection logic ([`Driver`]) on native: against a scripted socket with a
|
||||
//! made-up clock (hello, holding presence until the welcome, throttling, pings, a silent host,
|
||||
//! reconnecting as the same client, refusals, giving up), and against a real host over loopback through
|
||||
//! a small blocking socket, which checks that what the browser sends is what the host speaks.
|
||||
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::driver::{Driver, Socket};
|
||||
use crate::guest::GuestConfig;
|
||||
use crate::host::{ENDED, Host, HostConfig};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
use crate::loopback_tests::fast_limits;
|
||||
|
||||
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";
|
||||
const FILE: &str = "0123456789abcdef";
|
||||
|
||||
/// A socket that records what the driver does.
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
opened: Vec<String>,
|
||||
sent: Vec<ClientMsg>,
|
||||
closes: usize,
|
||||
/// `open` fails (the browser refused the URL).
|
||||
refuse: bool,
|
||||
/// `send` fails (the connection broke).
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Socket for Script {
|
||||
fn open(&mut self, url: &str) -> Result<(), String> {
|
||||
if self.refuse {
|
||||
return Err("SecurityError".into());
|
||||
}
|
||||
self.opened.push(url.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn send(&mut self, bytes: &[u8]) -> bool {
|
||||
if self.broken {
|
||||
return false;
|
||||
}
|
||||
self.sent.push(message::decode(bytes, usize::MAX).unwrap());
|
||||
true
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.closes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn invite() -> Invite {
|
||||
Invite { host: "127.0.0.1:47900".into(), file: FILE.into(), token: TOKEN.into() }
|
||||
}
|
||||
|
||||
fn start(sock: Script, t0: Instant) -> (Driver<Script>, Inbox) {
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
(Driver::start(sock, GuestConfig::new(invite(), "Ada".into()), inbox.clone(), t0), inbox)
|
||||
}
|
||||
|
||||
fn shared() -> Document {
|
||||
let mut doc = Document::new("Shared file");
|
||||
doc.repair();
|
||||
doc
|
||||
}
|
||||
|
||||
/// The welcome a session server sends Ada, encoded.
|
||||
fn welcome() -> Vec<u8> {
|
||||
let mut server = Server::new(shared(), TOKEN, Limits::default());
|
||||
let ClientMsg::Hello(h) = Client::hello(FILE, TOKEN, "Ada", "") else { panic!("no hello") };
|
||||
let (_, outs) = server.join(h).unwrap();
|
||||
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
message::encode(&w).unwrap()
|
||||
}
|
||||
|
||||
fn encode(m: &ServerMsg) -> Vec<u8> {
|
||||
message::encode(m).unwrap()
|
||||
}
|
||||
|
||||
fn statuses(events: &[Event]) -> Vec<Status> {
|
||||
events.iter().filter_map(|e| if let Event::Status(s) = e { Some(s.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn cursor(x: f64) -> Presence {
|
||||
Presence { cursor: Some((NodeId(1), x, 0.0)), ..Presence::default() }
|
||||
}
|
||||
|
||||
fn ms(n: u64) -> Duration {
|
||||
Duration::from_millis(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_then_the_welcome_releases_presence() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
assert_eq!(d.sock.opened, vec!["ws://127.0.0.1:47900/"], "the token isn't in the URL");
|
||||
assert_eq!(statuses(&inbox.drain()), vec![Status::Connecting]);
|
||||
// Nothing is due but giving up on a connection that never opens.
|
||||
assert_eq!(d.deadline(), Some(t0 + Duration::from_secs(5)));
|
||||
d.opened(t0 + ms(10));
|
||||
let [ClientMsg::Hello(h)] = d.sock.sent.as_slice() else { panic!("{:?}", d.sock.sent) };
|
||||
assert_eq!((h.file.as_str(), h.token.as_str(), h.name.as_str()), (FILE, TOKEN, "Ada"));
|
||||
assert_eq!(h.resume, None, "a new participant");
|
||||
// Before the welcome: presence is held (latest wins); changes were made for no connection the
|
||||
// server knows, so they're dropped (the sync client sends again what the server didn't get).
|
||||
d.presence(cursor(1.0), t0 + ms(20));
|
||||
d.presence(cursor(2.0), t0 + ms(21));
|
||||
d.send(ClientMsg::Ack { seq: 1 }, t0 + ms(22));
|
||||
d.send(ClientMsg::Ack { seq: 2 }, t0 + ms(23));
|
||||
assert_eq!(d.sock.sent.len(), 1);
|
||||
d.message(&welcome(), t0 + ms(30));
|
||||
assert_eq!(d.sock.sent[1..], [ClientMsg::Presence(cursor(2.0))], "the latest presence; no stale changes");
|
||||
d.send(ClientMsg::Ack { seq: 3 }, t0 + ms(31));
|
||||
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ack { seq: 3 }), "welcomed: changes go out");
|
||||
let events = inbox.drain();
|
||||
assert!(
|
||||
matches!(events.as_slice(), [Event::Welcome { info, peers }, Event::Sync(ServerMsg::Welcome { .. })] if !info.network && peers.is_empty())
|
||||
);
|
||||
assert!(!d.done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_is_throttled_and_quiet_connections_ping() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
d.presence(cursor(1.0), t0);
|
||||
d.presence(cursor(2.0), t0 + ms(5));
|
||||
d.presence(cursor(3.0), t0 + ms(10));
|
||||
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(1.0))));
|
||||
// The latest waits for the interval, and the driver asks to be woken for it.
|
||||
let due = d.deadline().unwrap();
|
||||
assert_eq!(due, t0 + crate::throttle::PRESENCE_INTERVAL);
|
||||
d.tick(due);
|
||||
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0))));
|
||||
let n = d.sock.sent.len();
|
||||
// Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic).
|
||||
let ping_at = d.deadline().unwrap();
|
||||
assert_eq!(ping_at, due + Duration::from_secs(15));
|
||||
d.tick(ping_at - ms(1));
|
||||
assert_eq!(d.sock.sent.len(), n, "not yet");
|
||||
d.message(&encode(&ServerMsg::Pong), ping_at - ms(1));
|
||||
d.tick(ping_at);
|
||||
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
let first_id = inbox.drain().iter().find_map(|e| if let Event::Welcome { info, .. } = e { Some(info.me) } else { None }).unwrap();
|
||||
// Pings go out, nothing comes back for 45 s.
|
||||
for s in [15, 30] {
|
||||
d.tick(t0 + Duration::from_secs(s));
|
||||
}
|
||||
assert!(!d.done());
|
||||
let silent = t0 + Duration::from_secs(45);
|
||||
assert_eq!(d.deadline(), Some(silent));
|
||||
d.tick(silent);
|
||||
assert_eq!(d.sock.closes, 1);
|
||||
assert_eq!(statuses(&inbox.drain()), vec![Status::Reconnecting { attempt: 1 }]);
|
||||
// Changes made while disconnected are dropped (the next welcome starts over); presence waits.
|
||||
d.send(ClientMsg::Ack { seq: 9 }, silent);
|
||||
d.presence(cursor(7.0), silent);
|
||||
let again = d.deadline().unwrap();
|
||||
assert_eq!(again, silent + ms(500), "the first pause");
|
||||
d.tick(again);
|
||||
assert_eq!(d.sock.opened.len(), 2);
|
||||
d.opened(again);
|
||||
// Back as the same client, from the last sequence number it got.
|
||||
let Some(ClientMsg::Hello(h)) = d.sock.sent.last() else { panic!("no hello") };
|
||||
let r = h.resume.expect("resumes");
|
||||
assert_eq!((r.client_id, r.seq), (first_id, 0));
|
||||
d.message(&welcome(), again);
|
||||
assert_eq!(d.sock.sent.iter().filter(|m| matches!(m, ClientMsg::Ack { seq: 9 })).count(), 0);
|
||||
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(7.0))), "your presence once you're back");
|
||||
assert!(inbox.drain().iter().any(|e| matches!(e, Event::Welcome { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnects_back_off_then_give_up() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
let mut now = t0;
|
||||
let mut pauses = Vec::new();
|
||||
d.closed(1006, "", now);
|
||||
while !d.done() {
|
||||
let next = d.deadline().unwrap();
|
||||
pauses.push(next - now);
|
||||
now = next;
|
||||
d.tick(now);
|
||||
// The host is gone: every attempt fails before it opens.
|
||||
d.closed(1006, "", now);
|
||||
}
|
||||
let secs: Vec<f64> = pauses.iter().map(Duration::as_secs_f64).collect();
|
||||
assert_eq!(secs, [0.5, 1.0, 2.0, 4.0, 8.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]);
|
||||
let st = statuses(&inbox.drain());
|
||||
assert_eq!(st.len(), 14, "connecting, 12 reconnects, then failed");
|
||||
assert!(matches!(st.last(), Some(Status::Failed { reason }) if reason.starts_with("Lost the connection to the host")), "{st:?}");
|
||||
assert_eq!(d.deadline(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_join_that_cant_reach_the_host_fails_at_once() {
|
||||
let t0 = Instant::now();
|
||||
// The browser gives up on the socket before it opens.
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.closed(1006, "", t0 + ms(30));
|
||||
assert!(d.done());
|
||||
let st = statuses(&inbox.drain());
|
||||
assert!(
|
||||
matches!(st.as_slice(), [Status::Connecting, Status::Failed { reason }] if reason == "Couldn't join the live session: no answer from 127.0.0.1:47900"),
|
||||
"{st:?}"
|
||||
);
|
||||
// No answer within the connect timeout.
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.tick(t0 + Duration::from_secs(5));
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.contains("no answer")));
|
||||
// The browser refuses to open the URL at all.
|
||||
let (d, inbox) = start(Script { refuse: true, ..Script::default() }, t0);
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.contains("SecurityError")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals_and_the_end_of_the_session_are_final() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&encode(&ServerMsg::Refused { reason: "wrong token".into() }), t0);
|
||||
assert!(d.done() && d.sock.closes == 1);
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason == "The host refused: wrong token"));
|
||||
// A close frame saying the host went away: the session ended.
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
d.closed(1001, "", t0);
|
||||
assert!(d.done());
|
||||
assert!(inbox.drain().iter().any(|e| matches!(e, Event::Ended { reason } if reason == ENDED)));
|
||||
// Garbage and text frames are refusals too.
|
||||
for text in [false, true] {
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
if text {
|
||||
d.text(t0);
|
||||
} else {
|
||||
d.message(&[0xFF, 1, 2, 3], t0);
|
||||
}
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_closes_and_reports_nothing() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
inbox.drain();
|
||||
d.leave(t0);
|
||||
assert!(d.done());
|
||||
assert_eq!(d.sock.closes, 1);
|
||||
assert_eq!(d.deadline(), None);
|
||||
// Late events from the closed socket change nothing.
|
||||
d.closed(1000, "", t0);
|
||||
d.message(&welcome(), t0);
|
||||
d.text(t0);
|
||||
d.tick(t0 + Duration::from_secs(60));
|
||||
assert!(inbox.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_send_reconnects() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
inbox.drain();
|
||||
d.sock.broken = true;
|
||||
d.send(ClientMsg::Ping, t0);
|
||||
assert_eq!(statuses(&inbox.drain()), vec![Status::Reconnecting { attempt: 1 }]);
|
||||
}
|
||||
|
||||
/// A blocking tungstenite socket standing in for the browser's: events are read by [`pump`].
|
||||
#[derive(Default)]
|
||||
struct Loopback {
|
||||
ws: Option<WebSocket<TcpStream>>,
|
||||
just_opened: bool,
|
||||
}
|
||||
|
||||
impl Socket for Loopback {
|
||||
fn open(&mut self, url: &str) -> Result<(), String> {
|
||||
let host = url.trim_start_matches("ws://").trim_end_matches('/');
|
||||
let stream = TcpStream::connect(host).map_err(|e| e.to_string())?;
|
||||
let (ws, _) = tungstenite::client(url, stream).map_err(|e| e.to_string())?;
|
||||
ws.get_ref().set_read_timeout(Some(ms(5))).map_err(|e| e.to_string())?;
|
||||
self.ws = Some(ws);
|
||||
self.just_opened = true;
|
||||
Ok(())
|
||||
}
|
||||
fn send(&mut self, bytes: &[u8]) -> bool {
|
||||
self.ws.as_mut().is_some_and(|ws| ws.send(Message::Binary(Bytes::copy_from_slice(bytes))).is_ok())
|
||||
}
|
||||
fn close(&mut self) {
|
||||
if let Some(mut ws) = self.ws.take() {
|
||||
let _ = ws.close(None);
|
||||
let _ = ws.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed the driver what arrived, as the browser's event loop would.
|
||||
fn pump(d: &mut Driver<Loopback>) {
|
||||
let now = Instant::now();
|
||||
if std::mem::take(&mut d.sock.just_opened) {
|
||||
d.opened(now);
|
||||
}
|
||||
while let Some(ws) = d.sock.ws.as_mut() {
|
||||
match ws.read() {
|
||||
Ok(Message::Binary(b)) => d.message(&b, now),
|
||||
Ok(Message::Close(f)) => {
|
||||
let (code, reason) = f.map_or((1005, String::new()), |f| (f.code.into(), f.reason.to_string()));
|
||||
d.closed(code, &reason, now);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::Io(e)) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => break,
|
||||
Err(_) => d.closed(1006, "", now),
|
||||
}
|
||||
}
|
||||
if d.deadline().is_some_and(|t| t <= now) {
|
||||
d.tick(now);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_a_real_host_and_edits_with_it() {
|
||||
let host_probe = Inbox::new(Arc::new(|| {}));
|
||||
let mut host = Host::start(
|
||||
HostConfig {
|
||||
addr: ([127, 0, 0, 1], 0).into(),
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
doc: shared(),
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
core: Limits::default(),
|
||||
},
|
||||
host_probe.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let invite = Invite { host: host.addr().to_string(), file: FILE.into(), token: TOKEN.into() };
|
||||
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();
|
||||
let wait = |d: &mut Driver<Loopback>, events: &mut Vec<Event>, what: &str, f: &dyn Fn(&[Event]) -> bool| {
|
||||
let start = Instant::now();
|
||||
while !f(events) {
|
||||
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}: {events:#?}");
|
||||
pump(d);
|
||||
events.extend(inbox.drain());
|
||||
}
|
||||
};
|
||||
wait(&mut d, &mut events, "the welcome", &|e| e.iter().any(|e| matches!(e, Event::Sync(ServerMsg::Welcome { .. }))));
|
||||
let Some(Event::Sync(w)) = events.iter().find(|e| matches!(e, Event::Sync(ServerMsg::Welcome { .. }))).cloned() else { panic!("no welcome") };
|
||||
let mut client = Client::join(w).unwrap();
|
||||
// Ada adds a page; the host's server accepts it and confirms it to her.
|
||||
let mut doc = client.document().clone();
|
||||
let page = doc.add_page("From the browser");
|
||||
let batch = client.edit(&doc).unwrap().unwrap();
|
||||
d.send(batch, Instant::now());
|
||||
wait(&mut d, &mut events, "the confirmation", &|e| e.iter().any(|e| matches!(e, Event::Sync(ServerMsg::Changes { .. }))));
|
||||
// The host's own window (a client of its server) gets the page too.
|
||||
let host_events: Vec<Event> = {
|
||||
let start = Instant::now();
|
||||
let mut got = Vec::new();
|
||||
while !got.iter().any(|e| matches!(e, Event::Sync(ServerMsg::Changes { .. }))) {
|
||||
assert!(start.elapsed() < Duration::from_secs(10), "the host never got the change: {got:#?}");
|
||||
pump(&mut d);
|
||||
got.extend(host_probe.drain());
|
||||
}
|
||||
got
|
||||
};
|
||||
assert!(host_events.iter().any(|e| matches!(e, Event::PeerJoined(p) if p.name == "Ada")));
|
||||
// Presence reaches the host.
|
||||
d.presence(cursor(42.0), Instant::now());
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
pump(&mut d);
|
||||
if host_probe.drain().iter().any(|e| matches!(e, Event::Presence(_, p) if *p == cursor(42.0))) {
|
||||
break;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(10), "presence never arrived");
|
||||
}
|
||||
// The host stops: the browser hears the session ended.
|
||||
host.stop();
|
||||
wait(&mut d, &mut events, "the end", &|e| e.iter().any(|e| matches!(e, Event::Ended { .. })));
|
||||
assert!(d.done());
|
||||
assert!(doc.contains(page));
|
||||
}
|
||||
|
|
@ -7,53 +7,23 @@
|
|||
|
||||
use std::io;
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::message::{self, ClientMsg, Peer, Presence, Resume, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use tungstenite::protocol::frame::coding::CloseCode;
|
||||
use tungstenite::protocol::{CloseFrame, Role as WsRole, WebSocket};
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::host::ENDED;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
use crate::net::{Duplex, NetLimits, config, shutdown};
|
||||
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::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// How a guest connects and reconnects.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GuestConfig {
|
||||
pub invite: Invite,
|
||||
/// Your name (empty: the host picks one).
|
||||
pub name: String,
|
||||
pub limits: NetLimits,
|
||||
pub connect_timeout: Duration,
|
||||
/// The pause before the first reconnect; it doubles each time up to `max_backoff`.
|
||||
pub backoff: Duration,
|
||||
pub max_backoff: Duration,
|
||||
/// Reconnect attempts before giving up.
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
impl GuestConfig {
|
||||
pub fn new(invite: Invite, name: String) -> Self {
|
||||
Self {
|
||||
invite,
|
||||
name,
|
||||
limits: NetLimits::default(),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
backoff: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(10),
|
||||
attempts: 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A joined (or joining) session. Dropping it leaves.
|
||||
pub struct Guest {
|
||||
cmd: Sender<Cmd>,
|
||||
|
|
@ -71,18 +41,6 @@ enum Cmd {
|
|||
Lost(u64, Outcome),
|
||||
}
|
||||
|
||||
/// How a connection ended.
|
||||
enum Outcome {
|
||||
/// Dropped: try again.
|
||||
Retry(String),
|
||||
/// The host said no (bad token, full…): final.
|
||||
Refused(String),
|
||||
/// The host ended the session: final.
|
||||
Ended(String),
|
||||
/// You left.
|
||||
Left,
|
||||
}
|
||||
|
||||
impl Guest {
|
||||
/// Start joining (returns at once; progress arrives in `inbox`).
|
||||
pub fn start(cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
|
||||
|
|
@ -157,17 +115,8 @@ fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
|
|||
Ok(Conn { reader, writer, sock })
|
||||
}
|
||||
|
||||
fn backoff(cfg: &GuestConfig, attempt: u32) -> Duration {
|
||||
cfg.backoff.saturating_mul(1u32 << attempt.saturating_sub(1).min(16)).min(cfg.max_backoff)
|
||||
}
|
||||
|
||||
/// Who this guest is in the session and the last sequence number it got (from the welcome and
|
||||
/// the batches since): what a reconnect resumes with. The engine applies every message it got,
|
||||
/// in order, before the next welcome, so its sync client is at that sequence number by then.
|
||||
type Resuming = Arc<Mutex<Option<Resume>>>;
|
||||
|
||||
fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<Cmd>) {
|
||||
let resume: Resuming = Arc::new(Mutex::new(None));
|
||||
let resume: Arc<Resuming> = Arc::default();
|
||||
let mut attempt = 0u32;
|
||||
let mut was_live = false;
|
||||
let mut pending: Option<Presence> = None;
|
||||
|
|
@ -179,21 +128,15 @@ fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<C
|
|||
Ok(c) => session(c, cfg, inbox, rx, me, epoch, &resume, &mut pending, &mut was_live, &mut attempt),
|
||||
Err(e) => Outcome::Retry(e),
|
||||
};
|
||||
match outcome {
|
||||
Outcome::Left => return,
|
||||
Outcome::Refused(why) => return inbox.push(Event::Status(Status::Failed { reason: format!("The host refused: {why}") })),
|
||||
Outcome::Ended(why) => return inbox.push(Event::Ended { reason: why }),
|
||||
Outcome::Retry(why) if !was_live => {
|
||||
return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't join the live session: {why}") }));
|
||||
}
|
||||
Outcome::Retry(why) => {
|
||||
attempt += 1;
|
||||
if attempt > cfg.attempts {
|
||||
return inbox.push(Event::Status(Status::Failed { reason: format!("Lost the connection to the host ({why})") }));
|
||||
match after(outcome, cfg, was_live, &mut attempt, inbox) {
|
||||
Next::Stop(e) => {
|
||||
if let Some(e) = e {
|
||||
inbox.push(e);
|
||||
}
|
||||
log::info!("live session: connection lost ({why}); reconnecting, attempt {attempt}");
|
||||
inbox.push(Event::Status(Status::Reconnecting { attempt }));
|
||||
let until = Instant::now() + backoff(cfg, attempt);
|
||||
return;
|
||||
}
|
||||
Next::Reconnect(pause) => {
|
||||
let until = Instant::now() + pause;
|
||||
loop {
|
||||
match rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
|
||||
Ok(Cmd::Leave) | Err(RecvTimeoutError::Disconnected) => return,
|
||||
|
|
@ -216,7 +159,7 @@ fn session(
|
|||
rx: &Receiver<Cmd>,
|
||||
me: &Sender<Cmd>,
|
||||
epoch: u64,
|
||||
resume: &Resuming,
|
||||
resume: &Arc<Resuming>,
|
||||
pending: &mut Option<Presence>,
|
||||
was_live: &mut bool,
|
||||
attempt: &mut u32,
|
||||
|
|
@ -233,11 +176,7 @@ fn session(
|
|||
}
|
||||
};
|
||||
let mut last_sent = Instant::now();
|
||||
let mut hello = Client::hello(&cfg.invite.file, &cfg.invite.token, &cfg.name, "");
|
||||
// Back after a drop: the same client, catching up from where it was.
|
||||
if let (ClientMsg::Hello(h), Some(r)) = (&mut hello, resume.lock().ok().and_then(|r| *r)) {
|
||||
h.resume = Some(r);
|
||||
}
|
||||
let hello = hello(cfg, resume);
|
||||
let mut welcomed = false;
|
||||
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
|
||||
let outcome = if send(&mut writer, &hello, &mut last_sent) {
|
||||
|
|
@ -301,36 +240,26 @@ struct ReaderCtx {
|
|||
tx: Sender<Cmd>,
|
||||
epoch: u64,
|
||||
cfg: GuestConfig,
|
||||
resume: Resuming,
|
||||
}
|
||||
|
||||
/// What the reader knows of the session (to tell the UI who's here).
|
||||
struct Seen {
|
||||
info: Option<SessionInfo>,
|
||||
peers: Vec<Peer>,
|
||||
resume: Arc<Resuming>,
|
||||
}
|
||||
|
||||
fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
|
||||
let mut seen = Seen { info: None, peers: Vec::new() };
|
||||
let mut seen = Seen::default();
|
||||
let outcome = loop {
|
||||
match ws.read() {
|
||||
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, ctx.cfg.limits.max_server_message) {
|
||||
Ok(m) => {
|
||||
if let Some(o) = take(m, ctx, &mut seen) {
|
||||
break o;
|
||||
let welcomed = || {
|
||||
let _ = ctx.tx.send(Cmd::Welcomed(ctx.epoch));
|
||||
};
|
||||
match take(m, &ctx.cfg, &ctx.inbox, &mut seen, &ctx.resume, &welcomed) {
|
||||
Taken::Continue | Taken::Welcomed => {}
|
||||
Taken::End(o) => break o,
|
||||
}
|
||||
}
|
||||
Err(e) => break Outcome::Refused(format!("the host sent a message this version can't read ({e})")),
|
||||
},
|
||||
Ok(Message::Close(f)) => {
|
||||
break match f {
|
||||
Some(f) if f.code == CloseCode::Away => {
|
||||
Outcome::Ended(if f.reason.is_empty() { ENDED.to_string() } else { f.reason.to_string() })
|
||||
}
|
||||
Some(f) if f.code == CloseCode::Policy => Outcome::Refused(f.reason.to_string()),
|
||||
_ => Outcome::Retry("the host closed the connection".into()),
|
||||
};
|
||||
}
|
||||
Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)),
|
||||
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
|
||||
|
|
@ -339,71 +268,3 @@ fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
|
|||
};
|
||||
let _ = ctx.tx.send(Cmd::Lost(ctx.epoch, outcome));
|
||||
}
|
||||
|
||||
/// Take in one message from the host; `Some` when the connection should end.
|
||||
fn take(m: ServerMsg, ctx: &ReaderCtx, seen: &mut Seen) -> Option<Outcome> {
|
||||
match m {
|
||||
ServerMsg::Welcome { session, client_id, seq, ref peers, .. } => {
|
||||
if let Ok(mut r) = ctx.resume.lock() {
|
||||
*r = Some(Resume { session, client_id, seq });
|
||||
}
|
||||
// The sync client opens the document (and fills in its name and pages).
|
||||
let peers = peers.clone();
|
||||
let inv = &ctx.cfg.invite;
|
||||
let network = inv.host.parse::<SocketAddr>().map_or(true, |a| !a.ip().is_loopback());
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: client_id,
|
||||
name: ctx.cfg.name.clone(),
|
||||
color: String::new(),
|
||||
file_name: String::new(),
|
||||
pages: Vec::new(),
|
||||
link: inv.link(),
|
||||
url: inv.url(),
|
||||
address: inv.host.clone(),
|
||||
network,
|
||||
};
|
||||
seen.peers = peers.clone();
|
||||
seen.info = Some(info.clone());
|
||||
// The writer goes live first: what the sync client sends in answer to this welcome
|
||||
// must go out on this connection.
|
||||
let _ = ctx.tx.send(Cmd::Welcomed(ctx.epoch));
|
||||
ctx.inbox.push(Event::Welcome { info, peers });
|
||||
ctx.inbox.push(Event::Sync(m));
|
||||
}
|
||||
ServerMsg::PeerJoined(p) => match seen.info.as_mut() {
|
||||
// The host tells you how you are shown.
|
||||
Some(info) if p.client_id == info.me => {
|
||||
info.name = p.name;
|
||||
info.color = p.color;
|
||||
ctx.inbox.push(Event::Welcome { info: info.clone(), peers: seen.peers.clone() });
|
||||
}
|
||||
_ => {
|
||||
seen.peers.retain(|q| q.client_id != p.client_id);
|
||||
seen.peers.push(p.clone());
|
||||
ctx.inbox.push(Event::PeerJoined(p));
|
||||
}
|
||||
},
|
||||
ServerMsg::PeerLeft { client_id } => {
|
||||
seen.peers.retain(|q| q.client_id != client_id);
|
||||
ctx.inbox.push(Event::PeerLeft(client_id));
|
||||
}
|
||||
ServerMsg::Presence { client_id, presence } => {
|
||||
if seen.info.as_ref().is_some_and(|i| i.me != client_id) {
|
||||
ctx.inbox.presence(client_id, presence);
|
||||
}
|
||||
}
|
||||
ServerMsg::Refused { reason } => return Some(Outcome::Refused(reason)),
|
||||
ServerMsg::Changes(ref acc) => {
|
||||
if let Ok(mut r) = ctx.resume.lock()
|
||||
&& let Some(r) = r.as_mut()
|
||||
{
|
||||
r.seq = acc.seq;
|
||||
}
|
||||
ctx.inbox.push(Event::Sync(m));
|
||||
}
|
||||
ServerMsg::Reject { .. } => ctx.inbox.push(Event::Sync(m)),
|
||||
ServerMsg::Pong => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,15 +29,13 @@ use tungstenite::protocol::{CloseFrame, Role, WebSocket};
|
|||
use tungstenite::{Bytes, Message};
|
||||
|
||||
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);
|
||||
|
||||
/// What the host tells everyone when it stops (the WebSocket close reason).
|
||||
pub const ENDED: &str = "The host ended the session";
|
||||
|
||||
/// What a hosted session needs.
|
||||
pub struct HostConfig {
|
||||
/// The address to listen on (port 0: any free port).
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ impl Invite {
|
|||
format!("ws://{}/", self.host)
|
||||
}
|
||||
|
||||
/// The host is this machine (a loopback address or `localhost`).
|
||||
pub fn loopback(&self) -> bool {
|
||||
match self.host.parse::<std::net::SocketAddr>() {
|
||||
Ok(a) => a.ip().is_loopback(),
|
||||
Err(_) => self.host.rsplit_once(':').is_some_and(|(name, _)| name.eq_ignore_ascii_case("localhost")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an invite link (either 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=…";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
//! What joining a session means on any transport, shared by the native guest ([`crate::guest`])
|
||||
//! and the browser client: the join settings, how a connection ended and what follows, and taking
|
||||
//! in what the host sends.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
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).
|
||||
pub(crate) const CLOSE_AWAY: u16 = 1001;
|
||||
pub(crate) const CLOSE_POLICY: u16 = 1008;
|
||||
|
||||
/// How a guest connects and reconnects.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GuestConfig {
|
||||
pub invite: Invite,
|
||||
/// Your name (empty: the host picks one).
|
||||
pub name: String,
|
||||
pub limits: NetLimits,
|
||||
pub connect_timeout: Duration,
|
||||
/// The pause before the first reconnect; it doubles each time up to `max_backoff`.
|
||||
pub backoff: Duration,
|
||||
pub max_backoff: Duration,
|
||||
/// Reconnect attempts before giving up.
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
impl GuestConfig {
|
||||
pub fn new(invite: Invite, name: String) -> Self {
|
||||
Self {
|
||||
invite,
|
||||
name,
|
||||
limits: NetLimits::default(),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
backoff: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(10),
|
||||
attempts: 12,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pause before reconnect `attempt` (from 1).
|
||||
pub(crate) fn backoff(&self, attempt: u32) -> Duration {
|
||||
self.backoff.saturating_mul(1u32 << attempt.saturating_sub(1).min(16)).min(self.max_backoff)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a connection ended.
|
||||
pub(crate) enum Outcome {
|
||||
/// Dropped: try again.
|
||||
Retry(String),
|
||||
/// The host said no (bad token, full…): final.
|
||||
Refused(String),
|
||||
/// The host ended the session: final.
|
||||
Ended(String),
|
||||
/// You left.
|
||||
Left,
|
||||
}
|
||||
|
||||
/// What a close frame from the host means.
|
||||
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()),
|
||||
_ => Outcome::Retry("the host closed the connection".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a guest does after a connection ended.
|
||||
pub(crate) enum Next {
|
||||
/// Done: tell the UI this (nothing when you left).
|
||||
Stop(Option<Event>),
|
||||
/// Connect again after this pause (the UI has been told: `Reconnecting`).
|
||||
Reconnect(Duration),
|
||||
}
|
||||
|
||||
/// After `outcome`: a refusal or the end of the session is final, and so is a first join that
|
||||
/// can't reach the host; a live connection that dropped tries again with growing pauses until the
|
||||
/// attempts run out.
|
||||
pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt: &mut u32, inbox: &Inbox) -> Next {
|
||||
let failed = |reason: String| Next::Stop(Some(Event::Status(Status::Failed { reason })));
|
||||
match outcome {
|
||||
Outcome::Left => Next::Stop(None),
|
||||
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
|
||||
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
|
||||
Outcome::Retry(why) => {
|
||||
*attempt += 1;
|
||||
if *attempt > cfg.attempts {
|
||||
return failed(format!("Lost the connection to the host ({why})"));
|
||||
}
|
||||
log::info!("live session: connection lost ({why}); reconnecting, attempt {attempt}");
|
||||
inbox.push(Event::Status(Status::Reconnecting { attempt: *attempt }));
|
||||
Next::Reconnect(cfg.backoff(*attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who this guest is in the session and the last sequence number it got (from the welcome and the
|
||||
/// batches since): what a reconnect resumes with. The engine applies every message it got, in
|
||||
/// order, before the next welcome, so its sync client is at that sequence number by then. (Shared
|
||||
/// by the native guest's reader and writer threads.)
|
||||
pub(crate) type Resuming = Mutex<Option<Resume>>;
|
||||
|
||||
/// The hello for a new connection: back after a drop, the same client catching up from where it
|
||||
/// was.
|
||||
pub(crate) fn hello(cfg: &GuestConfig, resume: &Resuming) -> ClientMsg {
|
||||
let mut hello = Client::hello(&cfg.invite.file, &cfg.invite.token, &cfg.name, "");
|
||||
if let (ClientMsg::Hello(h), Some(r)) = (&mut hello, resume.lock().ok().and_then(|r| *r)) {
|
||||
h.resume = Some(r);
|
||||
}
|
||||
hello
|
||||
}
|
||||
|
||||
/// What a connection knows of the session (to tell the UI who's here).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Seen {
|
||||
info: Option<SessionInfo>,
|
||||
peers: Vec<Peer>,
|
||||
}
|
||||
|
||||
/// What taking in one message from the host means for the connection.
|
||||
pub(crate) enum Taken {
|
||||
Continue,
|
||||
/// The welcome: the connection is live (send the presence that waited).
|
||||
Welcomed,
|
||||
/// The connection should end.
|
||||
End(Outcome),
|
||||
}
|
||||
|
||||
/// Take in one message from the host: events for the UI go to `inbox`, and `resume` follows the
|
||||
/// session. On the welcome, `welcomed` runs before the UI hears of it: what the sync client sends
|
||||
/// 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, .. } => {
|
||||
if let Ok(mut r) = resume.lock() {
|
||||
*r = Some(Resume { session, client_id, seq });
|
||||
}
|
||||
// The sync client opens the document (and fills in its name and pages).
|
||||
let peers = peers.clone();
|
||||
let inv = &cfg.invite;
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: client_id,
|
||||
name: cfg.name.clone(),
|
||||
color: String::new(),
|
||||
file_name: String::new(),
|
||||
pages: Vec::new(),
|
||||
link: inv.link(),
|
||||
url: inv.url(),
|
||||
address: inv.host.clone(),
|
||||
network: !inv.loopback(),
|
||||
};
|
||||
seen.peers = peers.clone();
|
||||
seen.info = Some(info.clone());
|
||||
welcomed();
|
||||
inbox.push(Event::Welcome { info, peers });
|
||||
inbox.push(Event::Sync(m));
|
||||
return Taken::Welcomed;
|
||||
}
|
||||
ServerMsg::PeerJoined(p) => match seen.info.as_mut() {
|
||||
// The host tells you how you are shown.
|
||||
Some(info) if p.client_id == info.me => {
|
||||
info.name = p.name;
|
||||
info.color = p.color;
|
||||
inbox.push(Event::Welcome { info: info.clone(), peers: seen.peers.clone() });
|
||||
}
|
||||
_ => {
|
||||
seen.peers.retain(|q| q.client_id != p.client_id);
|
||||
seen.peers.push(p.clone());
|
||||
inbox.push(Event::PeerJoined(p));
|
||||
}
|
||||
},
|
||||
ServerMsg::PeerLeft { client_id } => {
|
||||
seen.peers.retain(|q| q.client_id != client_id);
|
||||
inbox.push(Event::PeerLeft(client_id));
|
||||
}
|
||||
ServerMsg::Presence { client_id, presence } => {
|
||||
if seen.info.as_ref().is_some_and(|i| i.me != client_id) {
|
||||
inbox.presence(client_id, presence);
|
||||
}
|
||||
}
|
||||
ServerMsg::Refused { reason } => return Taken::End(Outcome::Refused(reason)),
|
||||
ServerMsg::Changes(ref acc) => {
|
||||
if let Ok(mut r) = resume.lock()
|
||||
&& let Some(r) = r.as_mut()
|
||||
{
|
||||
r.seq = acc.seq;
|
||||
}
|
||||
inbox.push(Event::Sync(m));
|
||||
}
|
||||
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
|
||||
ServerMsg::Pong => {}
|
||||
}
|
||||
Taken::Continue
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
//! Live sessions over the network for FigCraft (milestone M9, step 2 of
|
||||
//! `docs/collab-protocol.md`): the WebSocket transport, the session host the desktop app embeds,
|
||||
//! Live sessions over the network for FigCraft (milestone M9, steps 2 and 4
|
||||
//! of `docs/collab-protocol.md`): the WebSocket transport, the session host the desktop app embeds,
|
||||
//! invite links, and presence relay. It carries the collaboration core's messages
|
||||
//! ([`figcraft_collab::message`]) and runs its [`Server`](figcraft_collab::Server) as the host's
|
||||
//! authority.
|
||||
|
|
@ -8,12 +8,18 @@
|
|||
//! engine's `collab.*` commands drive it and the UI reads the engine's live state. Network
|
||||
//! threads wake the UI through a [`Waker`] only when something arrives; nothing polls.
|
||||
//!
|
||||
//! The browser build has a stand-in [`NetLink`] whose host and join say it's not available yet
|
||||
//! (a `web-sys` WebSocket client is step 4).
|
||||
//! The browser build's [`NetLink`] joins sessions through the browser's WebSocket API (`web-sys`);
|
||||
//! hosting needs the desktop app. Its connection logic (`driver.rs`) is target-independent and
|
||||
//! tested natively.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod driver;
|
||||
pub mod inbox;
|
||||
pub mod invite;
|
||||
mod join;
|
||||
pub mod limits;
|
||||
pub mod throttle;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod guest;
|
||||
|
|
@ -22,9 +28,9 @@ pub mod host;
|
|||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod link;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod net;
|
||||
pub mod links;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod throttle;
|
||||
pub mod net;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod token;
|
||||
|
||||
|
|
@ -33,14 +39,19 @@ mod web;
|
|||
|
||||
pub use inbox::{Inbox, Waker};
|
||||
pub use invite::Invite;
|
||||
pub use limits::NetLimits;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use link::{DEFAULT_PORT, NetLink, lan_ip};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::NetLink;
|
||||
|
||||
#[cfg(test)]
|
||||
mod driver_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_tests;
|
||||
#[cfg(test)]
|
||||
mod links_tests;
|
||||
#[cfg(test)]
|
||||
mod loopback_tests;
|
||||
#[cfg(test)]
|
||||
mod session_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
//! Transport limits, shared by the native transport and the browser client.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Transport limits (the collaboration core has its own: [`figcraft_collab::Limits`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct NetLimits {
|
||||
/// Sockets a host keeps open at once, joined or not.
|
||||
pub max_connections: usize,
|
||||
/// Largest message a client may send once it has joined (a batch can carry images), and
|
||||
/// before: its hello.
|
||||
pub max_client_message: usize,
|
||||
pub max_hello: usize,
|
||||
/// Largest message a client accepts from the host (a welcome carries the whole document).
|
||||
pub max_server_message: usize,
|
||||
/// Messages a client may send per second on average, and in a burst; beyond that it is
|
||||
/// disconnected.
|
||||
pub rate: f64,
|
||||
pub burst: f64,
|
||||
/// A new connection must finish the WebSocket handshake and say hello within this.
|
||||
pub hello_timeout: Duration,
|
||||
/// A connection with no traffic for this long is dead.
|
||||
pub idle_timeout: Duration,
|
||||
/// A quiet client pings this often (keeps it under `idle_timeout`).
|
||||
pub keepalive: Duration,
|
||||
/// Messages queued for one client before it counts as stuck and is disconnected.
|
||||
pub queue: usize,
|
||||
/// How long a write may block.
|
||||
pub write_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for NetLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_connections: 64,
|
||||
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
max_hello: 64 << 10,
|
||||
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
rate: 100.0,
|
||||
burst: 400.0,
|
||||
hello_timeout: Duration::from_secs(10),
|
||||
idle_timeout: Duration::from_secs(45),
|
||||
keepalive: Duration::from_secs(15),
|
||||
queue: 1024,
|
||||
write_timeout: Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
//! Making the desktop app the opener of `figcraft://` invite links (the engine's
|
||||
//! [`LinkRegistrar`]). On Windows that's a per-user URL scheme under
|
||||
//! `HKEY_CURRENT_USER\Software\Classes\figcraft` (no administrator rights; uninstalling the user's
|
||||
//! registration removes nothing else); the shell then starts `figcraft.exe "<link>"`, and the app
|
||||
//! joins the session it names. macOS and Linux declare the scheme in their packages
|
||||
//! (`CFBundleURLTypes`, the desktop entry's `x-scheme-handler/figcraft`), so there is nothing to
|
||||
//! register at run time.
|
||||
//!
|
||||
//! The registry sits behind [`Registry`] so tests use a fake: nothing here touches the real
|
||||
//! registry unless the app installs [`system`]'s registrar and the user (or an installed build's
|
||||
//! default) asks for it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use figcraft_engine::invite_links::LinkRegistrar;
|
||||
|
||||
/// The scheme's key under the current user's hive.
|
||||
pub const KEY: &str = r"Software\Classes\figcraft";
|
||||
|
||||
/// String values in a registry hive (the current user's).
|
||||
pub trait Registry: Send {
|
||||
/// The value `name` of `key` (`""`: the key's default value), if there is one.
|
||||
fn get(&self, key: &str, name: &str) -> Option<String>;
|
||||
/// Set the string value `name` of `key`, creating the key.
|
||||
fn set(&mut self, key: &str, name: &str, value: &str) -> Result<(), String>;
|
||||
/// Delete `key` and everything under it.
|
||||
fn remove_tree(&mut self, key: &str) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// The app's registration as the opener of `figcraft://` links.
|
||||
pub struct UrlScheme<R> {
|
||||
reg: R,
|
||||
exe: PathBuf,
|
||||
installed: bool,
|
||||
}
|
||||
|
||||
impl<R: Registry> UrlScheme<R> {
|
||||
/// For the app at `exe`; `installed` sets the preference's default (see [`installed_build`]).
|
||||
pub fn new(reg: R, exe: PathBuf, installed: bool) -> Self {
|
||||
Self { reg, exe, installed }
|
||||
}
|
||||
|
||||
/// The command the shell runs for a link.
|
||||
pub fn command(&self) -> String {
|
||||
format!("\"{}\" \"%1\"", self.exe.display())
|
||||
}
|
||||
|
||||
fn values(&self) -> [(String, &'static str, String); 4] {
|
||||
[
|
||||
(KEY.into(), "", "URL:FigCraft invite link".into()),
|
||||
(KEY.into(), "URL Protocol", String::new()),
|
||||
(format!(r"{KEY}\DefaultIcon"), "", format!("\"{}\",0", self.exe.display())),
|
||||
(format!(r"{KEY}\shell\open\command"), "", self.command()),
|
||||
]
|
||||
}
|
||||
|
||||
/// The registry, for tests.
|
||||
pub fn registry(&self) -> &R {
|
||||
&self.reg
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Registry> LinkRegistrar for UrlScheme<R> {
|
||||
fn registered(&self) -> bool {
|
||||
self.reg.get(&format!(r"{KEY}\shell\open\command"), "").is_some_and(|c| c.eq_ignore_ascii_case(&self.command()))
|
||||
}
|
||||
|
||||
fn installed(&self) -> bool {
|
||||
self.installed
|
||||
}
|
||||
|
||||
fn set(&mut self, on: bool) -> Result<(), String> {
|
||||
if on {
|
||||
for (key, name, value) in self.values() {
|
||||
self.reg.set(&key, name, &value)?;
|
||||
}
|
||||
Ok(())
|
||||
} else if self.registered() {
|
||||
self.reg.remove_tree(KEY)
|
||||
} else {
|
||||
// Another copy's registration (or none): not ours to remove.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `exe` is an installed copy of the app: a release build that isn't inside a Cargo build
|
||||
/// directory (which holds a `CACHEDIR.TAG`), so `cargo run` and test builds never register.
|
||||
pub fn installed_build(exe: &Path) -> bool {
|
||||
!cfg!(debug_assertions) && !exe.ancestors().skip(1).any(|d| d.join("CACHEDIR.TAG").is_file())
|
||||
}
|
||||
|
||||
/// This machine's registrar for the running app, where the app registers the scheme itself
|
||||
/// (Windows); `None` elsewhere.
|
||||
pub fn system() -> Option<Box<dyn LinkRegistrar>> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let installed = installed_build(&exe);
|
||||
Some(Box::new(UrlScheme::new(hkcu::CurrentUser, exe, installed)))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod hkcu {
|
||||
use windows_registry::CURRENT_USER;
|
||||
|
||||
/// `HKEY_CURRENT_USER`.
|
||||
pub struct CurrentUser;
|
||||
|
||||
impl super::Registry for CurrentUser {
|
||||
fn get(&self, key: &str, name: &str) -> Option<String> {
|
||||
CURRENT_USER.open(key).ok()?.get_string(name).ok()
|
||||
}
|
||||
fn set(&mut self, key: &str, name: &str, value: &str) -> Result<(), String> {
|
||||
CURRENT_USER.create(key).and_then(|k| k.set_string(name, value)).map_err(|e| e.message())
|
||||
}
|
||||
fn remove_tree(&mut self, key: &str) -> Result<(), String> {
|
||||
CURRENT_USER.remove_tree(key).map_err(|e| e.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
//! The `figcraft://` registration against a fake registry (tests never write the real one).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::invite_links::LinkRegistrar;
|
||||
|
||||
use crate::links::{KEY, Registry, UrlScheme, installed_build};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
values: BTreeMap<(String, String), String>,
|
||||
deny: bool,
|
||||
}
|
||||
|
||||
impl Registry for Fake {
|
||||
fn get(&self, key: &str, name: &str) -> Option<String> {
|
||||
self.values.get(&(key.to_string(), name.to_string())).cloned()
|
||||
}
|
||||
fn set(&mut self, key: &str, name: &str, value: &str) -> Result<(), String> {
|
||||
if self.deny {
|
||||
return Err("access denied".into());
|
||||
}
|
||||
self.values.insert((key.to_string(), name.to_string()), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn remove_tree(&mut self, key: &str) -> Result<(), String> {
|
||||
self.values.retain(|(k, _), _| k != key && !k.starts_with(&format!("{key}\\")));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn exe(path: &str) -> PathBuf {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_the_scheme_for_this_copy() {
|
||||
let mut s = UrlScheme::new(Fake::default(), exe(r"C:\Apps\FigCraft\figcraft.exe"), true);
|
||||
assert!(!s.registered());
|
||||
s.set(true).unwrap();
|
||||
assert!(s.registered());
|
||||
let reg = s.registry();
|
||||
assert_eq!(reg.get(KEY, "").as_deref(), Some("URL:FigCraft invite link"));
|
||||
assert_eq!(reg.get(KEY, "URL Protocol").as_deref(), Some(""), "marks the key as a URL scheme");
|
||||
assert_eq!(reg.get(&format!(r"{KEY}\DefaultIcon"), "").as_deref(), Some(r#""C:\Apps\FigCraft\figcraft.exe",0"#));
|
||||
assert_eq!(reg.get(&format!(r"{KEY}\shell\open\command"), "").as_deref(), Some(r#""C:\Apps\FigCraft\figcraft.exe" "%1""#));
|
||||
assert_eq!(reg.values.len(), 4, "nothing else is written");
|
||||
s.set(false).unwrap();
|
||||
assert!(!s.registered());
|
||||
assert!(s.registry().values.is_empty(), "unregistering removes the whole key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_another_copys_registration() {
|
||||
let mut other = UrlScheme::new(Fake::default(), exe(r"C:\Other\figcraft.exe"), true);
|
||||
other.set(true).unwrap();
|
||||
let reg = Fake { values: other.registry().values.clone(), deny: false };
|
||||
let mut s = UrlScheme::new(reg, exe(r"C:\Apps\figcraft.exe"), true);
|
||||
assert!(!s.registered(), "another copy is the opener");
|
||||
s.set(false).unwrap();
|
||||
assert_eq!(s.registry().get(&format!(r"{KEY}\shell\open\command"), "").as_deref(), Some(r#""C:\Other\figcraft.exe" "%1""#));
|
||||
// Turning it on takes it over.
|
||||
s.set(true).unwrap();
|
||||
assert!(s.registered());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_reported() {
|
||||
let mut s = UrlScheme::new(Fake { deny: true, ..Fake::default() }, exe(r"C:\Apps\figcraft.exe"), true);
|
||||
assert_eq!(s.set(true).unwrap_err(), "access denied");
|
||||
assert!(!s.registered());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_builds_are_not_installed() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-links-{}", std::process::id()));
|
||||
let target = dir.join("target");
|
||||
let bin = target.join("release");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::write(target.join("CACHEDIR.TAG"), "Signature: 8a477f597d28d172789f06886806bc55").unwrap();
|
||||
assert!(!installed_build(&bin.join("figcraft.exe")), "inside a Cargo build directory");
|
||||
let installed = dir.join("Programs").join("FigCraft");
|
||||
std::fs::create_dir_all(&installed).unwrap();
|
||||
// Debug builds (these tests) never count as installed either.
|
||||
assert_eq!(installed_build(&installed.join("figcraft.exe")), !cfg!(debug_assertions));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// The test binary itself runs from a Cargo build directory.
|
||||
assert!(!installed_build(&std::env::current_exe().unwrap()));
|
||||
}
|
||||
|
|
@ -11,53 +11,10 @@ use std::net::{Shutdown, TcpStream};
|
|||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub use crate::limits::NetLimits;
|
||||
|
||||
use tungstenite::protocol::WebSocketConfig;
|
||||
|
||||
/// Transport limits (the collaboration core has its own: [`figcraft_collab::Limits`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct NetLimits {
|
||||
/// Sockets a host keeps open at once, joined or not.
|
||||
pub max_connections: usize,
|
||||
/// Largest message a client may send once it has joined (a batch can carry images), and
|
||||
/// before: its hello.
|
||||
pub max_client_message: usize,
|
||||
pub max_hello: usize,
|
||||
/// Largest message a client accepts from the host (a welcome carries the whole document).
|
||||
pub max_server_message: usize,
|
||||
/// Messages a client may send per second on average, and in a burst; beyond that it is
|
||||
/// disconnected.
|
||||
pub rate: f64,
|
||||
pub burst: f64,
|
||||
/// A new connection must finish the WebSocket handshake and say hello within this.
|
||||
pub hello_timeout: Duration,
|
||||
/// A connection with no traffic for this long is dead.
|
||||
pub idle_timeout: Duration,
|
||||
/// A quiet client pings this often (keeps it under `idle_timeout`).
|
||||
pub keepalive: Duration,
|
||||
/// Messages queued for one client before it counts as stuck and is disconnected.
|
||||
pub queue: usize,
|
||||
/// How long a write may block.
|
||||
pub write_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for NetLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_connections: 64,
|
||||
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
max_hello: 64 << 10,
|
||||
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
rate: 100.0,
|
||||
burst: 400.0,
|
||||
hello_timeout: Duration::from_secs(10),
|
||||
idle_timeout: Duration::from_secs(45),
|
||||
keepalive: Duration::from_secs(15),
|
||||
queue: 1024,
|
||||
write_timeout: Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// tungstenite's settings for messages up to `max` bytes.
|
||||
pub fn config(max: usize) -> WebSocketConfig {
|
||||
WebSocketConfig::default().max_message_size(Some(max)).max_frame_size(Some(max))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
//! (replacing any older waiting value) until the interval has passed, so the last one is never
|
||||
//! lost. Used for presence on both ends (about 30 updates a second).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
// `std::time::Instant` on native builds; the browser's clock in the web build.
|
||||
use web_time::Instant;
|
||||
|
||||
/// How often presence goes out at most.
|
||||
pub const PRESENCE_INTERVAL: Duration = Duration::from_millis(33);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,211 @@
|
|||
//! The browser build's stand-in: live sessions need the desktop app until the `web-sys`
|
||||
//! WebSocket client lands (step 4 of `docs/collab-protocol.md`).
|
||||
//! 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
|
||||
//! events and wakes it with a timer. Hosting needs the desktop app (a page can't listen on a
|
||||
//! socket).
|
||||
//!
|
||||
//! The page runs everything on one thread: each session's socket, callbacks and timer live in a
|
||||
//! 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.
|
||||
|
||||
use figcraft_engine::live::{Event, HostOptions, LiveLink, Presence, SessionInfo};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::inbox::Waker;
|
||||
use figcraft_collab::message::{ClientMsg, Presence};
|
||||
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionInfo};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
|
||||
use web_time::Instant;
|
||||
|
||||
pub struct NetLink;
|
||||
use crate::driver::{Driver, Socket};
|
||||
use crate::inbox::{Inbox, Waker};
|
||||
use crate::invite::Invite;
|
||||
use crate::join::GuestConfig;
|
||||
|
||||
/// Close code for leaving normally.
|
||||
const CLOSE_NORMAL: u16 = 1000;
|
||||
|
||||
/// One session's driver and the timer that wakes it.
|
||||
struct Guest {
|
||||
driver: Driver<BrowserSocket>,
|
||||
/// Runs the driver's `tick` (one closure for the session's life, reused for every wake).
|
||||
timer: Closure<dyn FnMut()>,
|
||||
/// The pending `setTimeout`.
|
||||
timeout: Option<i32>,
|
||||
}
|
||||
|
||||
impl Guest {
|
||||
/// Set the timer for the driver's next deadline (replacing the last one).
|
||||
fn arm(&mut self) {
|
||||
let Some(w) = web_sys::window() else { return };
|
||||
if let Some(t) = self.timeout.take() {
|
||||
w.clear_timeout_with_handle(t);
|
||||
}
|
||||
let Some(at) = self.driver.deadline() else { return };
|
||||
let ms = i32::try_from(at.saturating_duration_since(Instant::now()).as_millis()).unwrap_or(i32::MAX);
|
||||
self.timeout = w.set_timeout_with_callback_and_timeout_and_arguments_0(self.timer.as_ref().unchecked_ref(), ms).ok();
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static GUESTS: RefCell<BTreeMap<u64, Rc<RefCell<Guest>>>> = RefCell::default();
|
||||
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
|
||||
}
|
||||
|
||||
/// Run `f` on session `id`'s driver, then set its timer for what's next, or forget the session
|
||||
/// once it's over. (Callbacks may drop their own closures: wasm-bindgen frees a closure only
|
||||
/// after it has returned.)
|
||||
fn with_driver(id: u64, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
|
||||
let Some(g) = GUESTS.with(|t| t.borrow().get(&id).cloned()) else { return };
|
||||
let done = {
|
||||
let Ok(mut g) = g.try_borrow_mut() else {
|
||||
log::warn!("live session: an event arrived while the session was busy");
|
||||
return;
|
||||
};
|
||||
f(&mut g.driver, Instant::now());
|
||||
g.arm();
|
||||
g.driver.done()
|
||||
};
|
||||
if done {
|
||||
GUESTS.with(|t| t.borrow_mut().remove(&id));
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket's callbacks (alive while the connection is in use).
|
||||
struct Handlers {
|
||||
_open: Closure<dyn FnMut()>,
|
||||
_message: Closure<dyn FnMut(MessageEvent)>,
|
||||
_close: Closure<dyn FnMut(CloseEvent)>,
|
||||
}
|
||||
|
||||
/// The browser's WebSocket behind the driver's [`Socket`].
|
||||
struct BrowserSocket {
|
||||
id: u64,
|
||||
ws: Option<(WebSocket, Handlers)>,
|
||||
}
|
||||
|
||||
impl Socket for BrowserSocket {
|
||||
fn open(&mut self, url: &str) -> Result<(), String> {
|
||||
self.close();
|
||||
let ws = WebSocket::new(url).map_err(|e| format!("the browser won't open {url}: {}", js_error(&e)))?;
|
||||
ws.set_binary_type(BinaryType::Arraybuffer);
|
||||
let id = self.id;
|
||||
let open = Closure::<dyn FnMut()>::new(move || with_driver(id, |d, now| d.opened(now)));
|
||||
let message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| match e.data().dyn_into::<js_sys::ArrayBuffer>() {
|
||||
Ok(buf) => {
|
||||
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
|
||||
with_driver(id, |d, now| d.message(&bytes, now));
|
||||
}
|
||||
Err(_) => with_driver(id, |d, now| d.text(now)),
|
||||
});
|
||||
let close = Closure::<dyn FnMut(CloseEvent)>::new(move |e: CloseEvent| {
|
||||
let (code, reason) = (e.code(), e.reason());
|
||||
with_driver(id, |d, now| d.closed(code, &reason, now));
|
||||
});
|
||||
ws.set_onopen(Some(open.as_ref().unchecked_ref()));
|
||||
ws.set_onmessage(Some(message.as_ref().unchecked_ref()));
|
||||
ws.set_onclose(Some(close.as_ref().unchecked_ref()));
|
||||
self.ws = Some((ws, Handlers { _open: open, _message: message, _close: close }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send(&mut self, bytes: &[u8]) -> bool {
|
||||
self.ws.as_ref().is_some_and(|(ws, _)| ws.ready_state() == WebSocket::OPEN && ws.send_with_u8_array(bytes).is_ok())
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
let Some((ws, _handlers)) = self.ws.take() else { return };
|
||||
ws.set_onopen(None);
|
||||
ws.set_onmessage(None);
|
||||
ws.set_onclose(None);
|
||||
let _ = ws.close_with_code(CLOSE_NORMAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn js_error(e: &wasm_bindgen::JsValue) -> String {
|
||||
e.dyn_ref::<js_sys::Error>().map_or_else(|| format!("{e:?}"), |e| String::from(e.message()))
|
||||
}
|
||||
|
||||
/// The page was loaded over `https://`.
|
||||
fn secure_page() -> bool {
|
||||
web_sys::window().and_then(|w| w.location().protocol().ok()).is_some_and(|p| p == "https:")
|
||||
}
|
||||
|
||||
/// The browser's [`LiveLink`]: joins one session at a time; hosting needs the desktop app.
|
||||
pub struct NetLink {
|
||||
wake: Waker,
|
||||
active: Option<u64>,
|
||||
inbox: Option<Inbox>,
|
||||
}
|
||||
|
||||
impl NetLink {
|
||||
pub fn new(_wake: Waker) -> Self {
|
||||
NetLink
|
||||
/// `wake` is called whenever something arrives for the UI (egui's `request_repaint`).
|
||||
pub fn new(wake: Waker) -> Self {
|
||||
Self { wake, active: None, inbox: None }
|
||||
}
|
||||
|
||||
fn with(&self, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
|
||||
if let Some(id) = self.active {
|
||||
with_driver(id, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveLink for NetLink {
|
||||
fn can_host(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("hosting a live session needs the desktop app".into())
|
||||
Err("hosting a live session needs the desktop app; in the browser you can join one".into())
|
||||
}
|
||||
fn join(&mut self, link: &str, _: &str) -> Result<(), String> {
|
||||
crate::invite::Invite::parse(link)?;
|
||||
Err("joining live sessions from the browser comes in a later version; use the desktop app".into())
|
||||
|
||||
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());
|
||||
}
|
||||
self.leave();
|
||||
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
let driver = Driver::start(BrowserSocket { id, ws: None }, GuestConfig::new(invite, name.to_string()), inbox.clone(), Instant::now());
|
||||
let timer = Closure::<dyn FnMut()>::new(move || with_driver(id, |d, now| d.tick(now)));
|
||||
let mut g = Guest { driver, timer, timeout: None };
|
||||
g.arm();
|
||||
if !g.driver.done() {
|
||||
GUESTS.with(|t| t.borrow_mut().insert(id, Rc::new(RefCell::new(g))));
|
||||
}
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(id);
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self) {}
|
||||
fn presence(&mut self, _: &Presence) {}
|
||||
fn send(&mut self, _: figcraft_collab::message::ClientMsg) {}
|
||||
|
||||
fn leave(&mut self) {
|
||||
self.with(|d, now| d.leave(now));
|
||||
self.active = None;
|
||||
self.inbox = None;
|
||||
}
|
||||
|
||||
fn presence(&mut self, p: &Presence) {
|
||||
self.with(|d, now| d.presence(p.clone(), now));
|
||||
}
|
||||
|
||||
fn send(&mut self, msg: ClientMsg) {
|
||||
self.with(|d, now| d.send(msg, now));
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
Vec::new()
|
||||
self.inbox.as_ref().map(Inbox::drain).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NetLink {
|
||||
fn drop(&mut self) {
|
||||
self.leave();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ fn registry() -> &'static Registry {
|
|||
list.extend(crate::collab::specs());
|
||||
list.extend(crate::plugins::specs());
|
||||
list.extend(crate::live::specs());
|
||||
list.extend(crate::invite_links::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
//! Opening invite links from the system: a `figcraft://join?…` link clicked in a browser, a chat
|
||||
//! or an email opens FigCraft, which joins the session (milestone M9).
|
||||
//!
|
||||
//! The app makes itself the opener of the `figcraft` URL scheme. Where the app registers it at run
|
||||
//! time (Windows: per user, no administrator rights), the desktop app installs a
|
||||
//! [`LinkRegistrar`] (like the [`LiveLink`](crate::live::LiveLink)); the engine keeps the
|
||||
//! preference ([`Prefs::invite_links`](crate::Prefs::invite_links)) and the
|
||||
//! `collab.registerLinks` command. The preference defaults to on for installed builds and off for
|
||||
//! development builds, so running from a source checkout never takes the scheme over. (macOS and
|
||||
//! Linux packages declare the scheme in the bundle and the desktop entry instead.)
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::boolean;
|
||||
use crate::{EngineError, Result, Session, spec};
|
||||
|
||||
/// Registers this copy of the app as the opener of `figcraft://` links (the desktop app installs
|
||||
/// one: `session.live.links`).
|
||||
pub trait LinkRegistrar: Send {
|
||||
/// This copy of the app is the registered opener.
|
||||
fn registered(&self) -> bool;
|
||||
/// This is an installed build (the preference defaults to on), not a development build.
|
||||
fn installed(&self) -> bool;
|
||||
/// Register this copy (`on`), or remove its registration (`off`: another copy's stays).
|
||||
fn set(&mut self, on: bool) -> std::result::Result<(), String>;
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Whether invite links should open this app: the preference, or on for installed builds.
|
||||
/// `None` when this app can't register them.
|
||||
pub fn invite_links_on(&self) -> Option<bool> {
|
||||
let r = self.live.links.as_ref()?;
|
||||
Some(self.prefs.invite_links.unwrap_or_else(|| r.installed()))
|
||||
}
|
||||
|
||||
/// At startup: register as the opener when the preference says so and it isn't already this
|
||||
/// copy; remove this copy's registration only when the preference was turned off.
|
||||
pub fn invite_links_startup(&mut self) {
|
||||
let pref = self.prefs.invite_links;
|
||||
let Some(r) = self.live.links.as_mut() else { return };
|
||||
let want = match pref {
|
||||
Some(on) => on,
|
||||
None if r.installed() => true,
|
||||
// A development build leaves whatever is registered alone.
|
||||
None => return,
|
||||
};
|
||||
if want != r.registered()
|
||||
&& let Err(e) = r.set(want)
|
||||
{
|
||||
log::warn!("invite links: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.registerLinks";
|
||||
let Some(r) = s.live.links.as_mut() else {
|
||||
return Err(EngineError::Disabled(
|
||||
CMD.into(),
|
||||
"this app doesn't register link openers itself (the Windows desktop app does; the macOS and Linux packages declare figcraft:// when installed)"
|
||||
.into(),
|
||||
));
|
||||
};
|
||||
let on = boolean(p, "on").unwrap_or_else(|| !s.prefs.invite_links.unwrap_or_else(|| r.installed()));
|
||||
r.set(on).map_err(|e| EngineError::Other(format!("couldn't {} invite links: {e}", if on { "register" } else { "unregister" })))?;
|
||||
let registered = r.registered();
|
||||
s.prefs.invite_links = Some(on);
|
||||
Ok(json!({"on": on, "registered": registered}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"collab.registerLinks",
|
||||
"Open invite links in FigCraft",
|
||||
"{on? (default: switch)} → {on, registered}: make this app the opener of figcraft:// invite links (or stop); also the Preferences checkbox",
|
||||
false,
|
||||
register
|
||||
)]
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
//! `collab.registerLinks` and the startup registration, against a fake registrar (tests never
|
||||
//! touch the real system).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::invite_links::LinkRegistrar;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
registered: bool,
|
||||
calls: Vec<bool>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
struct Fake {
|
||||
installed: bool,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
impl LinkRegistrar for Fake {
|
||||
fn registered(&self) -> bool {
|
||||
self.state.lock().unwrap().registered
|
||||
}
|
||||
fn installed(&self) -> bool {
|
||||
self.installed
|
||||
}
|
||||
fn set(&mut self, on: bool) -> Result<(), String> {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
if s.fail {
|
||||
return Err("access denied".into());
|
||||
}
|
||||
s.calls.push(on);
|
||||
s.registered = on;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn session(installed: bool, registered: bool) -> (Session, Arc<Mutex<State>>) {
|
||||
let state = Arc::new(Mutex::new(State { registered, ..State::default() }));
|
||||
let mut s = Session::new();
|
||||
s.live.links = Some(Box::new(Fake { installed, state: state.clone() }));
|
||||
(s, state)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_registrar_the_command_is_disabled() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.invite_links_on(), None);
|
||||
s.invite_links_startup();
|
||||
let e = s.execute("collab.registerLinks", &json!({"on": true})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::Disabled(..)), "{e}");
|
||||
assert_eq!(s.prefs.invite_links, None, "the preference is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_builds_register_on_first_run() {
|
||||
let (mut s, state) = session(true, false);
|
||||
assert_eq!(s.invite_links_on(), Some(true));
|
||||
s.invite_links_startup();
|
||||
assert_eq!(state.lock().unwrap().calls, vec![true]);
|
||||
// Already this copy: nothing written again.
|
||||
s.invite_links_startup();
|
||||
assert_eq!(state.lock().unwrap().calls, vec![true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn development_builds_leave_the_registration_alone() {
|
||||
// Not registered, or another copy's registration: a build from source changes neither.
|
||||
for registered in [false, true] {
|
||||
let (mut s, state) = session(false, registered);
|
||||
assert_eq!(s.invite_links_on(), Some(false));
|
||||
s.invite_links_startup();
|
||||
assert!(state.lock().unwrap().calls.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preference_wins_at_startup() {
|
||||
let (mut s, state) = session(false, false);
|
||||
s.prefs.invite_links = Some(true);
|
||||
s.invite_links_startup();
|
||||
assert_eq!(state.lock().unwrap().calls, vec![true]);
|
||||
let (mut s, state) = session(true, true);
|
||||
s.prefs.invite_links = Some(false);
|
||||
s.invite_links_startup();
|
||||
assert_eq!(state.lock().unwrap().calls, vec![false], "turned off: this copy's registration is removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_switches_and_remembers() {
|
||||
let (mut s, state) = session(true, false);
|
||||
// Without `on` it switches the effective setting (on for an installed build) off.
|
||||
let r = s.execute("collab.registerLinks", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"on": false, "registered": false}));
|
||||
assert_eq!(s.prefs.invite_links, Some(false));
|
||||
let r = s.execute("collab.registerLinks", &json!({"on": true})).unwrap();
|
||||
assert_eq!(r, json!({"on": true, "registered": true}));
|
||||
assert_eq!(s.prefs.invite_links, Some(true));
|
||||
assert_eq!(s.invite_links_on(), Some(true));
|
||||
assert_eq!(state.lock().unwrap().calls, vec![false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_registration_keeps_the_preference() {
|
||||
let (mut s, state) = session(true, false);
|
||||
state.lock().unwrap().fail = true;
|
||||
let e = s.execute("collab.registerLinks", &json!({"on": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("access denied"), "{e}");
|
||||
assert_eq!(s.prefs.invite_links, None);
|
||||
// Startup logs the failure and carries on.
|
||||
s.invite_links_startup();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_parameters_never_panic() {
|
||||
let (mut s, _) = session(true, false);
|
||||
for p in [json!(null), json!([]), json!({"on": "yes"}), json!({"on": 3}), json!({"on": null})] {
|
||||
let _ = s.execute("collab.registerLinks", &p);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ pub mod drop;
|
|||
pub mod guard;
|
||||
pub mod guides;
|
||||
pub mod inspect;
|
||||
pub mod invite_links;
|
||||
pub mod keymap;
|
||||
pub mod library;
|
||||
pub mod live;
|
||||
|
|
@ -451,6 +452,9 @@ pub struct Prefs {
|
|||
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
|
||||
/// too). Prototype transitions still play.
|
||||
pub reduce_motion: bool,
|
||||
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
|
||||
/// builds, off for development builds.
|
||||
pub invite_links: Option<bool>,
|
||||
/// Installed plugins (Plugins menu), in menu order.
|
||||
#[serde(serialize_with = "plugins::persisted")]
|
||||
pub plugins: Vec<plugins::InstalledPlugin>,
|
||||
|
|
@ -469,6 +473,7 @@ impl Default for Prefs {
|
|||
theme: "light".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1156,6 +1161,8 @@ mod html_codegen_tests;
|
|||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_variables_tests;
|
||||
|
|
|
|||
|
|
@ -180,6 +180,10 @@ pub struct HostOptions {
|
|||
/// 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 {
|
||||
/// Sessions can be hosted here (the browser build can only join).
|
||||
fn can_host(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// Start hosting a session for `opts.doc` (ending any current one).
|
||||
fn host(&mut self, opts: HostOptions) -> std::result::Result<SessionInfo, String>;
|
||||
/// Start joining the session behind an invite link (ending any current one). Errors are for
|
||||
|
|
@ -231,6 +235,9 @@ pub struct Live {
|
|||
deferred: Vec<ServerMsg>,
|
||||
/// Nesting of [`Session::grouped`] on the session document: its edits sync once, at the end.
|
||||
hold: u32,
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
|
|
@ -239,11 +246,16 @@ impl Live {
|
|||
self.link = Some(link);
|
||||
}
|
||||
|
||||
/// A link is installed: sessions can be hosted and joined.
|
||||
/// A link is installed: sessions can be joined (and hosted, when [`can_host`](Self::can_host)).
|
||||
pub fn available(&self) -> bool {
|
||||
self.link.is_some()
|
||||
}
|
||||
|
||||
/// Sessions can be hosted from here (a link is installed and it isn't the browser's).
|
||||
pub fn can_host(&self) -> bool {
|
||||
self.link.as_ref().is_some_and(|l| l.can_host())
|
||||
}
|
||||
|
||||
pub fn role(&self) -> Option<Role> {
|
||||
self.info.as_ref().map(|i| i.role)
|
||||
}
|
||||
|
|
@ -723,6 +735,7 @@ impl Session {
|
|||
.collect();
|
||||
json!({
|
||||
"available": l.available(),
|
||||
"canHost": l.can_host(),
|
||||
"status": l.status.id(),
|
||||
"reason": match &l.status { Status::Failed { reason } => Some(reason.as_str()), _ => None },
|
||||
"attempt": match l.status { Status::Reconnecting { attempt } => Some(attempt), _ => None },
|
||||
|
|
@ -745,7 +758,10 @@ impl Session {
|
|||
}
|
||||
|
||||
fn unavailable(cmd: &str) -> EngineError {
|
||||
EngineError::Disabled(cmd.to_string(), "live sessions aren't available here (the desktop app and the MCP server host and join them)".into())
|
||||
EngineError::Disabled(
|
||||
cmd.to_string(),
|
||||
"live sessions aren't available here (the desktop app and the MCP server host and join them; the web build joins)".into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn host(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -834,7 +850,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"collab.peers",
|
||||
"People in this file",
|
||||
"{} → {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), peers: [{id, name, color, 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, follow, syncing, document (index of the synced open document), peers: [{id, name, color, presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}",
|
||||
false,
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
|
|
|
|||
|
|
@ -130,6 +130,13 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
for c in toggles {
|
||||
app.run(c, json!({}));
|
||||
}
|
||||
// Where the app registers itself as the opener of invite links (Windows).
|
||||
if let Some(on) = app.session.invite_links_on() {
|
||||
if crate::widgets::checkbox(ui, on, "Open invite links in FigCraft") {
|
||||
app.run("collab.registerLinks", json!({"on": !on}));
|
||||
}
|
||||
crate::widgets::label(ui, "figcraft:// links from a browser, chat or email open this app and join the session.", t.text_secondary);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Your name in comments");
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ mod layers_keys_tests;
|
|||
#[cfg(test)]
|
||||
mod layers_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod live_join_tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_reorder_tests;
|
||||
|
|
@ -739,6 +741,7 @@ impl FigApp {
|
|||
present::show_pane(self, &ctx);
|
||||
comments::keys(self, &ctx);
|
||||
dev_compare::keys(self, &ctx);
|
||||
live::join_keys(self, &ctx);
|
||||
shortcuts::handle(self, &ctx, typing);
|
||||
menus::popups(self, &ctx);
|
||||
variable_ui::picker(self, &ctx);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ use crate::right::{menu_item, menu_item_with, menu_popup};
|
|||
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.";
|
||||
|
||||
/// How undo works in a session (per person).
|
||||
const UNDO_NOTE: &str = "Undo takes back only your own changes; what others changed since stays.";
|
||||
|
||||
|
|
@ -37,6 +41,8 @@ pub struct LiveUi {
|
|||
seen: Option<(f64, f64)>,
|
||||
/// The Join dialog's link field (open when `Some`).
|
||||
pub join: Option<String>,
|
||||
/// Put the keyboard in the Join dialog's field on its next frame.
|
||||
focus_join: bool,
|
||||
/// Start sessions on the local network (else this machine only).
|
||||
pub lan: bool,
|
||||
}
|
||||
|
|
@ -306,6 +312,14 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
|||
app.run("pref.userName", json!({"name": n}));
|
||||
}
|
||||
});
|
||||
if !app.session.live.can_host() {
|
||||
widgets::label(ui, "Hosting a session needs the desktop app; here you can join one.", t.text_secondary);
|
||||
if widgets::button(ui, "Join a session…", true, true).clicked() {
|
||||
open_join(app, "");
|
||||
ui.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if widgets::checkbox(ui, app.ui.live.lan, "Share on the local network") {
|
||||
app.ui.live.lan = !app.ui.live.lan;
|
||||
}
|
||||
|
|
@ -321,7 +335,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
|||
app.run("collab.host", json!({"bind": bind}));
|
||||
}
|
||||
if widgets::button(ui, "Join a session…", false, true).clicked() {
|
||||
app.ui.live.join = Some(String::new());
|
||||
open_join(app, "");
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -353,21 +367,51 @@ fn host_ui(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Open the Join dialog, with `link` in its field (focused).
|
||||
pub fn open_join(app: &mut FigApp, link: &str) {
|
||||
app.ui.live.join = Some(link.to_string());
|
||||
app.ui.live.focus_join = true;
|
||||
}
|
||||
|
||||
/// An invite link the app was opened with (`figcraft figcraft://join?…`): join at once; a link
|
||||
/// that can't be used shows in the Join dialog with the reason.
|
||||
pub fn open_invite(app: &mut FigApp, link: &str) {
|
||||
if app.run("collab.join", json!({"link": link.trim()})).is_none() {
|
||||
open_join(app, link.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let Some(mut link) = app.ui.live.join.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_join);
|
||||
crate::dialogs::window("Join live session", ctx).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
widgets::label(ui, "Paste the invite link the host shared with you.", t.text);
|
||||
let resp = ui
|
||||
.add(egui::TextEdit::singleline(&mut link).hint_text("figcraft://join?host=…").desired_width(380.0).id(egui::Id::new("live_join_link")));
|
||||
a11y::text(&resp, "Invite link", "");
|
||||
if focus {
|
||||
resp.request_focus();
|
||||
}
|
||||
go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
widgets::label(ui, "You'll get the host's file as a new tab and edit it together.", t.text_secondary);
|
||||
if !app.session.live.can_host() {
|
||||
widgets::label(ui, BROWSER_NOTE, t.text_secondary);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
go |= widgets::button(ui, "Join", true, !link.trim().is_empty()).clicked();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
//! The Join dialog's keyboard (its field takes the keys when it opens; Escape closes it without
|
||||
//! reaching the canvas), opening the app with an invite link, and the browser's Share popover
|
||||
//! (it joins but can't host).
|
||||
|
||||
use egui::{Event, Key};
|
||||
use figcraft_engine::live::{Event as LiveEvent, HostOptions, LiveLink, Presence, SessionInfo, Status};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live_tests::{Harness, has_text, text_center};
|
||||
|
||||
fn escape(h: &mut Harness) {
|
||||
for pressed in [true, false] {
|
||||
h.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]);
|
||||
}
|
||||
}
|
||||
|
||||
fn link_focused(h: &Harness) -> bool {
|
||||
h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_link")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_closes_the_join_dialog_and_keeps_the_selection() {
|
||||
let (mut h, rect) = Harness::new();
|
||||
h.app.session.execute("select.set", &json!({"ids": [rect.0]})).unwrap();
|
||||
crate::menus::invoke(&mut h.app, "collab.join");
|
||||
h.frames(2);
|
||||
assert!(h.app.ui.live.join.is_some());
|
||||
assert!(link_focused(&h), "the link field takes the keyboard when the dialog opens");
|
||||
// Escape while typing in the field.
|
||||
escape(&mut h);
|
||||
assert!(h.app.ui.live.join.is_none(), "Escape closes the dialog");
|
||||
assert_eq!(h.app.session.ds().unwrap().selection, vec![rect], "and doesn't deselect on the canvas");
|
||||
// Escape with the field not focused (after clicking the dialog's text).
|
||||
crate::menus::invoke(&mut h.app, "collab.join");
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Paste the invite link the host shared with you."));
|
||||
h.frames(1);
|
||||
assert!(!link_focused(&h));
|
||||
escape(&mut h);
|
||||
assert!(h.app.ui.live.join.is_none());
|
||||
assert_eq!(h.app.session.ds().unwrap().selection, vec![rect]);
|
||||
// With the dialog closed, Escape deselects as usual.
|
||||
h.click(egui::pos2(700.0, 500.0));
|
||||
h.app.session.execute("select.set", &json!({"ids": [rect.0]})).unwrap();
|
||||
escape(&mut h);
|
||||
assert!(h.app.session.ds().unwrap().selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_the_app_with_an_invite_link_joins() {
|
||||
let (mut h, _) = Harness::new();
|
||||
let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
crate::live::open_invite(&mut h.app, &format!(" {link} "));
|
||||
assert_eq!(h.app.session.live.status, Status::Connecting);
|
||||
assert!(h.app.ui.live.join.is_none(), "no dialog: it joins at once");
|
||||
}
|
||||
|
||||
/// The browser's link: joins, can't host; joining fails here to show the dialog path.
|
||||
struct Browser;
|
||||
|
||||
impl LiveLink for Browser {
|
||||
fn can_host(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("hosting a live session needs the desktop app".into())
|
||||
}
|
||||
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
|
||||
Err("that's not a live session invite".into())
|
||||
}
|
||||
fn leave(&mut self) {}
|
||||
fn presence(&mut self, _: &Presence) {}
|
||||
fn send(&mut self, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<LiveEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_that_cant_be_used_opens_the_dialog_with_it() {
|
||||
let (mut h, _) = Harness::new();
|
||||
h.app.session.live.install(Box::new(Browser));
|
||||
crate::live::open_invite(&mut h.app, "figcraft://nope");
|
||||
assert_eq!(h.app.ui.live.join.as_deref(), Some("figcraft://nope"), "the link stays in the field to fix");
|
||||
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("not a live session invite")));
|
||||
assert_eq!(h.app.session.live.status, Status::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_joins_but_does_not_host() {
|
||||
let (mut h, _) = Harness::new();
|
||||
h.app.session.live.install(Box::new(Browser));
|
||||
assert!(!h.app.session.live.can_host());
|
||||
assert_eq!(h.app.session.execute("collab.peers", &json!({})).unwrap()["canHost"], false);
|
||||
let shapes = h.frames(3);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Hosting a session needs the desktop app; here you can join one."));
|
||||
assert!(!has_text(&shapes, "Start live session") && !has_text(&shapes, "Share on the local network"));
|
||||
h.click(text_center(&shapes, "Join a session…"));
|
||||
let shapes = h.frames(2);
|
||||
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."
|
||||
));
|
||||
}
|
||||
|
|
@ -35,9 +35,9 @@ impl LiveLink for Mock {
|
|||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
pub(crate) struct Harness {
|
||||
pub(crate) ctx: egui::Context,
|
||||
pub(crate) app: FigApp,
|
||||
time: f64,
|
||||
events: Arc<Mutex<Vec<LiveEvent>>>,
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ fn flat(shapes: &[egui::epaint::ClippedShape]) -> Vec<Shape> {
|
|||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> (Self, NodeId) {
|
||||
pub(crate) fn new() -> (Self, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let rect = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
|
|
@ -69,7 +69,7 @@ impl Harness {
|
|||
}
|
||||
|
||||
/// One frame; the shapes it painted and the repaint delay it asked for.
|
||||
fn frame(&mut self, events: Vec<Event>) -> (Vec<Shape>, Duration) {
|
||||
pub(crate) fn frame(&mut self, events: Vec<Event>) -> (Vec<Shape>, Duration) {
|
||||
self.time += 1.0 / 60.0;
|
||||
let input =
|
||||
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
|
||||
|
|
@ -80,7 +80,7 @@ impl Harness {
|
|||
(flat(&out.shapes), delay)
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) -> Vec<Shape> {
|
||||
pub(crate) fn frames(&mut self, n: usize) -> Vec<Shape> {
|
||||
let mut last = Vec::new();
|
||||
for _ in 0..n {
|
||||
last = self.frame(vec![]).0;
|
||||
|
|
@ -88,7 +88,7 @@ impl Harness {
|
|||
last
|
||||
}
|
||||
|
||||
fn click(&mut self, p: Pos2) {
|
||||
pub(crate) fn click(&mut self, p: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
self.frame(vec![
|
||||
Event::PointerMoved(p),
|
||||
|
|
@ -150,12 +150,12 @@ fn texts(shapes: &[Shape]) -> Vec<(String, Pos2, egui::Vec2)> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn has_text(shapes: &[Shape], s: &str) -> bool {
|
||||
pub(crate) fn has_text(shapes: &[Shape], s: &str) -> bool {
|
||||
texts(shapes).iter().any(|(t, _, _)| t == s)
|
||||
}
|
||||
|
||||
/// The middle of the text `s` on screen.
|
||||
fn text_center(shapes: &[Shape], s: &str) -> Pos2 {
|
||||
pub(crate) fn text_center(shapes: &[Shape], s: &str) -> Pos2 {
|
||||
let (_, p, size) = texts(shapes).into_iter().find(|(t, _, _)| t == s).unwrap_or_else(|| panic!("no text {s:?}"));
|
||||
p + size / 2.0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ 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" => app.ui.live.join = Some(String::new()),
|
||||
"collab.join" => crate::live::open_join(app, ""),
|
||||
"edit.copy" | "edit.cut" => {
|
||||
let ids = app.session.selection();
|
||||
// SVG for other apps, taken before a cut removes the layers.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ reconciliation, derived data and the message encoding. **Step 2 is implemented**
|
|||
embeds, invite links, document sync end to end (several apps edit one file), live cursors,
|
||||
selections, avatars and follow. **Step 3 is implemented**: per-user undo and redo, rejection
|
||||
handling and offline queueing with reconnect (`crates/collab`, wired into the engine's live
|
||||
session and the transport). The rest is the plan the next implementation PRs follow and update;
|
||||
sections say what is built.
|
||||
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. 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
|
||||
|
|
@ -336,15 +337,15 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
|||
authority and takes part itself without a socket.
|
||||
- Links: `figcraft://join?host=<ip:port>&file=<id>&token=<secret>` or
|
||||
`ws://<ip:port>/?file=<id>&token=<secret>` (either works for joining). The client connects to
|
||||
`ws://<ip:port>/` and sends the token only inside its `Hello`. No `figcraft://` handler is
|
||||
registered with the operating system yet: links are pasted into the Join dialog.
|
||||
`ws://<ip:port>/` and sends the token only inside its `Hello`. Links are pasted into the Join
|
||||
dialog (its field has the keyboard when it opens; Escape closes it), or opened from anywhere:
|
||||
see *Opening invite links* below.
|
||||
- Reconnect: a connection that drops after it was live reconnects with growing pauses (0.5 s,
|
||||
doubling up to 10 s, 12 attempts); a refusal (wrong token, full) or the host ending the session
|
||||
is final; a first join that can't reach the host fails at once. A reconnect resumes as the same
|
||||
client: the guest's connection keeps the session id, client id and last sequence number from
|
||||
the messages it read and sends them in its next `Hello` (see Offline and reconnect).
|
||||
- The browser build has the commands but says live sessions need the desktop app (its WebSocket
|
||||
client is step 4).
|
||||
- The browser build joins (see *Built (the browser client)*); hosting needs the desktop app.
|
||||
|
||||
*Built* (document sync between apps):
|
||||
|
||||
|
|
@ -366,6 +367,51 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
|||
- Each applied batch wakes the window (the inbox calls `request_repaint`); nothing runs while
|
||||
nobody edits or moves.
|
||||
|
||||
*Built* (the browser client):
|
||||
|
||||
- The web build's link speaks the same binary messages through the browser's WebSocket API
|
||||
(`web-sys`, binary frames as `ArrayBuffer`). Its connection logic is a target-independent state
|
||||
machine (`crates/collab-net/src/driver.rs`) that mirrors the native guest: hello on open
|
||||
(resuming as the same client after a drop), presence held until the welcome and changes made
|
||||
for an earlier connection dropped (the sync client sends them again once caught up), presence throttled to about 30 updates a second
|
||||
(latest wins), a `Ping` after 15 s without sending (browsers don't expose WebSocket ping frames;
|
||||
the host answers `Pong`), a connection silent for 45 s counted as dropped, reconnects with the
|
||||
same growing pauses after a live connection drops, refusals and the end of the session final,
|
||||
and a first join that can't reach the host failing at once. The browser layer only feeds it the
|
||||
socket's events and one `setTimeout` for its next deadline, and every arrival wakes egui
|
||||
(`request_repaint`): an idle session draws nothing. The machine is tested natively against a
|
||||
scripted socket and against a real host over loopback.
|
||||
- The page runs on one thread: sockets, callbacks and timers live in a thread-local table and the
|
||||
engine's link (which must be `Send`) holds only the session's number and its inbox.
|
||||
- Hosting stays desktop-only (a page can't listen on a socket); the Share popover in the browser
|
||||
says so and offers only joining, and `collab.peers` reports `canHost: false`.
|
||||
- **Mixed content.** A desktop-hosted session is plain `ws://`. Browsers block `ws://` from a page
|
||||
loaded over `https://`, except to this machine (`127.0.0.1`, `[::1]`, `localhost`), so an https
|
||||
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.
|
||||
|
||||
*Built* (opening invite links):
|
||||
|
||||
- The desktop app takes an invite link on its command line (`figcraft "figcraft://join?…"`): once
|
||||
the window is up it joins (`collab.join`); a link it can't use opens the Join dialog with the
|
||||
link and the reason. Other arguments are files, as paths or `file://` URLs.
|
||||
- **Windows**: the app registers itself per user as the opener of `figcraft://` under
|
||||
`HKEY_CURRENT_USER\Software\Classes\figcraft` (no administrator rights): the scheme, its icon
|
||||
and `"<figcraft.exe>" "%1"`. Preferences ▸ **Open invite links in FigCraft** (and
|
||||
`collab.registerLinks {on?}`) turns it on or off; it defaults to on for installed builds, which
|
||||
register on first run, and off for builds from source (inside a Cargo build directory, or debug
|
||||
builds), which leave any registration alone. Turning it off removes only this copy's
|
||||
registration. The registry sits behind a trait, so tests use a fake.
|
||||
- **macOS**: the bundle declares the scheme (`CFBundleURLTypes`), so links open FigCraft; the
|
||||
link itself arrives as an Apple Event, which the windowing layer doesn't pass on yet, so the app
|
||||
opens without joining (paste the link) until it does.
|
||||
- **Linux and FreeBSD**: the desktop entry declares `x-scheme-handler/figcraft` and passes URLs
|
||||
(`%U`); the app joins from its command line.
|
||||
- An app that is already running doesn't receive the link: a second window starts and joins.
|
||||
|
||||
## Offline and reconnect
|
||||
|
||||
*Built* (`Client::{disconnect, reconnect}`, the server's log, the guest's connection in
|
||||
|
|
@ -473,5 +519,6 @@ chooses to share on the network; the default is the invite link with a random to
|
|||
3. **Per-user undo**, reconnect and offline queueing, rejection handling, presence for text
|
||||
editing. **Done** (the core's client and server, the engine's live session, the guest's
|
||||
resuming connection).
|
||||
4. **Browser client** (WebSocket through `web-sys`).
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (the multiplayer core landed in #103) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; 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 on one machine; the real-time collaboration core landed in #103
|
||||
(sync model, server ordering, client reconciliation, convergence tests) without networking, so
|
||||
nobody can co-edit yet ([`collab-protocol.md`](collab-protocol.md)). Remaining: 55–110 h
|
||||
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
|
||||
([`collab-protocol.md`](collab-protocol.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. Left: browser client, standalone server | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | no (presence messages encoded; no UI or transport) | 8–15 |
|
||||
| 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. Left: standalone server with TLS | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **partial**: cursors with name tags, selections, avatars and follow in live sessions; 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 |
|
||||
| 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) |
|
||||
|
|
@ -24,5 +25,6 @@ nobody can co-edit yet ([`collab-protocol.md`](collab-protocol.md)). Remaining:
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |
|
||||
|
|
|
|||
|
|
@ -91,8 +91,10 @@ screen reader announces; empty when nothing has it). `reduceMotion` is true when
|
|||
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`); the session itself is `engine.execute {command: "collab.peers"}` (hosting and
|
||||
joining are `collab.host` and `collab.join`, see [mcp.md](mcp.md#live-sessions)).
|
||||
(`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
|
||||
was started with (`figcraft "figcraft://join?…"`).
|
||||
|
||||
### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}`
|
||||
|
||||
|
|
|
|||
15
docs/gaps.md
15
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (merged #101–#103: plugins and the multiplayer core) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged) · **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
|
||||
|
|
@ -60,14 +60,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
- **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, document sync between apps, live cursors, selections, avatars and follow)
|
||||
with per-person undo, refused edits rolled back with a reason, and reconnects that resume and
|
||||
send what was made offline; but the browser can't join, and there are no share links or
|
||||
permissions (`docs/collab-protocol.md`).
|
||||
app, invite links that open the app, document sync between apps, live cursors, selections,
|
||||
avatars and follow, and the browser build joins) with per-person undo, refused edits rolled back
|
||||
with a reason, and reconnects that resume and send what was made offline; 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`).
|
||||
- **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; the browser build
|
||||
needs its own WebSocket client.
|
||||
- **Estimate:** 45–90 h: sharing and permissions and a standalone server with TLS.
|
||||
|
||||
### 3. Nothing checked against real Figma files
|
||||
- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and
|
||||
|
|
@ -217,6 +217,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-10 | minor | Marked beta blockers; no alpha blockers (the alpha gate passes) |
|
||||
| 2026-10-10 | major | Created from the 2026-10-10 re-measure against Figma desktop 126.10.7 (replaces the gap list that lived in ROADMAP.md) |
|
||||
|
|
|
|||
13
docs/mcp.md
13
docs/mcp.md
|
|
@ -489,8 +489,9 @@ is per user (`edit.undo` / `edit.redo` take back only your own steps, see
|
|||
### Live sessions
|
||||
|
||||
People (and agents) edit one file together and see each other's cursors, selections and
|
||||
viewports. Both the headless session and the attached app can host and join; each call takes in
|
||||
what happened since the last one and sends your edits (a joined file opens as a new document).
|
||||
viewports. Both the headless session and the attached app can host and join (the web build can
|
||||
join but not host); each call takes in what happened since the last one and sends your edits (a
|
||||
joined file opens as a new document).
|
||||
|
||||
- `collab.host {port?, bind?}` starts hosting the active document: `bind` is `"local"` (this
|
||||
machine, the default), `"lan"` (the local network) or an interface address; the port is 47900,
|
||||
|
|
@ -501,7 +502,8 @@ what happened since the last one and sends your edits (a joined file opens as a
|
|||
- `collab.join {link}` (either form) 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.
|
||||
- `collab.peers {}` → `{available, status (off|connecting|live|reconnecting|failed), reason?,
|
||||
- `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,
|
||||
follow, syncing (the document arrived), document (the index of the synced open document), peers: [{id, name, color, presence: {cursor: [page, x, y], selection: [ids], viewport:
|
||||
[page, x, y, w, h], editing_text: {node, caret, selection?}}}]}`.
|
||||
|
|
@ -514,6 +516,11 @@ what happened since the last one and sends your edits (a joined file opens as a
|
|||
An edit the host refuses (someone deleted the layer) is rolled back and the reason is the
|
||||
session's message. While the connection is reconnecting, editing goes on; the edits go out when
|
||||
it is back (the same client, catching up with what it missed).
|
||||
- `collab.registerLinks {on?}` → `{on, registered}` makes the desktop app the opener of
|
||||
`figcraft://` invite links, or stops (no `on`: switch). Only where the app registers them itself
|
||||
(Windows, per user); elsewhere it reports the command unavailable. It also sets the
|
||||
Preferences checkbox. Starting the app with an invite link (`figcraft "figcraft://join?…"`)
|
||||
joins that session.
|
||||
|
||||
### Plugins
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (merged #101–#103: plugins and the multiplayer core) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged) · **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**: the browser client, a standalone server and sharing on top of the live sessions (the WebSocket transport, hosting from the app, invite links, document sync, live cursors, selections, avatars and follow, per-user undo and reconnect with offline edits landed), per [`collab-protocol.md`](collab-protocol.md): 45–90 h.
|
||||
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, avatars and follow, per-user undo, reconnect with offline edits and joining from the browser landed), per [`collab-protocol.md`](collab-protocol.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; no real-time co-editing or share links yet; the sync core landed without networking ([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, 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 |
|
||||
| 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 | `figcraft://` link handler, browser client, standalone server, 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; 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 |
|
||||
| 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 | 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) |
|
||||
| 2026-10-10 | minor | Added the alpha gate (core-workflow table): passes, stage stays alpha |
|
||||
| 2026-10-10 | major | Created: milestone table moved from ROADMAP.md with statuses re-checked; added M10–M12; current focus re-planned |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (merged #101–#103: plugins and the multiplayer core; 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 (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged; last major: the 2026-10-10 full re-measure) · **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), no networking: WebSocket transport, hosting/joining, cursors, observation, 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. Left: per-person undo, offline resume, cursor chat, share links and permissions ([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% | 78–146 (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 without networking, so nobody can co-edit yet (`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, 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) |
|
||||
|
|
@ -572,6 +572,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (~400–750 h), mainstream (~180–330 h) and essentials (~85–160 h). Full number confirmed as an additive weighted sum; unchanged |
|
||||
| 2026-10-10 | minor | Added the essentials user number (~53%: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send) |
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ integrate() {
|
|||
mkdir -p "$apps" 2>/dev/null || return 0
|
||||
tmp="$apps/.$DESK_ID.tmp.$$"
|
||||
esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')"
|
||||
if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then
|
||||
if sed -e "s|^Exec=.*|Exec=\"$esc\" %U|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then
|
||||
if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then
|
||||
rm -f "$tmp"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Type=Application
|
|||
Name=FigCraft
|
||||
GenericName=Interface Design Tool
|
||||
Comment=Design and prototype user interfaces
|
||||
Exec=figcraft %F
|
||||
Exec=figcraft %U
|
||||
TryExec=figcraft
|
||||
Icon=ai.storyteller.figcraft
|
||||
Terminal=false
|
||||
|
|
@ -11,4 +11,4 @@ StartupNotify=true
|
|||
StartupWMClass=figcraft
|
||||
Categories=Graphics;2DGraphics;VectorGraphics;X-Design;
|
||||
Keywords=design;interface;ui;ux;prototype;wireframe;mockup;vector;layout;components;
|
||||
MimeType=application/x-figcraft;image/svg+xml;image/png;image/jpeg;
|
||||
MimeType=application/x-figcraft;image/svg+xml;image/png;image/jpeg;x-scheme-handler/figcraft;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ DESKTOP="$XDG_DATA_HOME/applications/$APP_ID.desktop"
|
|||
|
||||
# 2. Exec rewritten to the AppImage (quoted, spaces intact), TryExec dropped,
|
||||
# everything else preserved from the packaged entry.
|
||||
expected_exec="Exec=\"$(readlink -f "$APPIMAGE")\" %F"
|
||||
expected_exec="Exec=\"$(readlink -f "$APPIMAGE")\" %U"
|
||||
grep -Fxq "$expected_exec" "$DESKTOP" || fail "Exec not rewritten: $(grep '^Exec=' "$DESKTOP")"
|
||||
grep -q '^TryExec=' "$DESKTOP" && fail "TryExec kept, the entry would be hidden"
|
||||
grep -Fxq "Icon=$APP_ID" "$DESKTOP" || fail "Icon line lost"
|
||||
|
|
@ -69,7 +69,7 @@ after="$(stat -c %i "$DESKTOP")"
|
|||
mv "$APPIMAGE" "$TMP/moved.AppImage"
|
||||
export APPIMAGE="$TMP/moved.AppImage"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch after move exited non-zero"
|
||||
grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec not refreshed after move"
|
||||
grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %U" "$DESKTOP" || fail "Exec not refreshed after move"
|
||||
|
||||
# 6. Opt-out installs nothing.
|
||||
rm -rf "$XDG_DATA_HOME"
|
||||
|
|
|
|||
|
|
@ -70,6 +70,19 @@
|
|||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- figcraft://join?... live session invite links open FigCraft. -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>ai.storyteller.figcraft.invite</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array><string>figcraft</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ The editor runs entirely on the visitor's device. The web build starts with one
|
|||
there is no desktop TCP control server, and native file dialogs aren't wired up yet. A browser
|
||||
with WebGPU or WebGL2 is required.
|
||||
|
||||
## Live sessions
|
||||
|
||||
The web build can join a live session hosted by the desktop app (File ▸ Join live session…, then
|
||||
paste the invite link); it can't host one. Desktop-hosted sessions use plain `ws://`, which
|
||||
browsers block from a page served over `https://` except to the same computer. To join sessions on
|
||||
other machines, serve the site over `http://` on a trusted network (or use the desktop app);
|
||||
`wss://` comes with the standalone server.
|
||||
|
||||
## Sizes
|
||||
|
||||
Measured on the 0.1.0 build (`trunk build --release`; gzip `-9`, Brotli quality 11):
|
||||
|
|
|
|||
Loading…
Reference in New Issue