Merge main into #1420

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mert Dede
2026-10-30 01:47:34 +03:00
co-authored by Claude Opus 5.5
288 changed files with 11083 additions and 1544 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
with:
app-name: figcraft
app-dir: FigCraft
Generated
+78
View File
@@ -1041,6 +1041,17 @@ dependencies = [
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-settings"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-atomic",
"dirs",
"serde",
"toml_edit",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1266,6 +1277,27 @@ dependencies = [
"crypto-common 0.2.2",
]
[[package]]
name = "dirs"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1675,6 +1707,7 @@ version = "0.12.1"
dependencies = [
"arboard",
"craft-menu",
"craft-settings",
"craft-update",
"craft-window",
"eframe",
@@ -1904,6 +1937,7 @@ dependencies = [
"crc32fast",
"figcraft-color",
"figcraft-doc",
"figcraft-format",
"figcraft-geom",
"figcraft-pathops",
"figcraft-text",
@@ -1964,6 +1998,7 @@ dependencies = [
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
"icu_segmenter",
"log",
"objc2 0.6.5",
"png",
@@ -2023,6 +2058,7 @@ dependencies = [
"egui",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-text",
"figcraft-ui-egui",
"js-sys",
"log",
@@ -2742,6 +2778,29 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"core_maths",
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -3685,6 +3744,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4266,6 +4331,16 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "redox_users"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
dependencies = [
"libredox",
"thiserror 2.0.21",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -5228,8 +5303,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
+7
View File
@@ -51,6 +51,8 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff13
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Where the settings live: the suite's folder rules, portable mode included (craft-libs).
craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -78,6 +80,11 @@ skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
# Line breaks between the words of Thai, Lao, Khmer and Burmese, which are written without spaces
# (#1219): ICU4X's line segmenter with its compiled data (pure Rust, Unicode-3.0 like the icu_*
# crates already in the lock, whose version it matches). figcraft-text picks the dictionaries, or
# in the browser the smaller LSTM models.
icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] }
# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
deunicode = "1.6"
+33 -2
View File
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -1,5 +1,6 @@
//! What one address may do how often: new connections, publishing without the key and messages
//! (only on a server open to the public), and reports and downloads (always). Each is a token
//! (only on a server open to the public), and reports, downloads and requests that change the
//! server's files (always, with the server key too). Each is a token
//! bucket per block an address counts in ([`ip_blocks`]: an IPv4 address, or an IPv6 /64 and its
//! /56 and /48 with 2 and 4 times the rate), so a tunnel's /48 doesn't get 65,536 /64s' worth. Publishing has a budget for
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
@@ -34,6 +35,10 @@ pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
/// times what the widest block (an IPv6 /48, four addresses' worth) may, so no block takes it all.
pub const PUBLISHES_ALL: f64 = 32.0;
/// Requests that change the server's files (join codes, links, renames, removals, blocks,
/// publishing with the key) one address may make a minute, with the server key or an owner token.
pub const ADMIN_PER_MINUTE: f64 = 60.0;
/// Join codes one address may try a minute (right or wrong).
pub const CODES_PER_MINUTE: f64 = 10.0;
/// Wrong codes in a row (within [`CODE_LOCKOUT`] of each other) that lock an address out of codes.
@@ -66,6 +71,8 @@ pub enum Action {
Download(usize),
/// Trying a join code.
Code,
/// A request that changes the server's files ([`ADMIN_PER_MINUTE`]).
Admin,
}
struct Address {
@@ -76,6 +83,7 @@ struct Address {
reports: Bucket,
downloads: Bucket,
codes: Bucket,
admin: Bucket,
/// Wrong join codes in a row, and when the last one was.
wrong: u32,
wrong_at: Instant,
@@ -177,6 +185,7 @@ impl Abuse {
reports: bucket(REPORTS_PER_HOUR * times, 3600.0),
downloads: Bucket::new(DOWNLOAD_RATE * times, DOWNLOAD_BURST * times),
codes: bucket(CODES_PER_MINUTE * times, 60.0),
admin: bucket(ADMIN_PER_MINUTE * times, 60.0),
wrong: 0,
wrong_at: now,
locked: None,
@@ -195,6 +204,7 @@ impl Abuse {
Action::Report => (&mut a.reports, 1.0),
Action::Download(n) => (&mut a.downloads, n as f64),
Action::Code => (&mut a.codes, 1.0),
Action::Admin => (&mut a.admin, 1.0),
}
}
@@ -0,0 +1,85 @@
//! Requests that change the server's files have a budget per address, with the server key or an
//! owner token too; join codes are found through an index of their hashes, with the same results
//! as comparing every link.
use std::net::IpAddr;
use figcraft_collab::message::{Admin, AdminReply};
use figcraft_collab_net::hub::Backend;
use crate::abuse::ADMIN_PER_MINUTE;
use crate::store::{BUSY_ADMIN, CODE_GONE, Store};
use crate::test_util::{TempDir, config, ip, snapshot};
fn rename(store: &mut Store, from: IpAddr, file: &str) -> AdminReply {
Backend::admin(store, from, Admin::Rename { file: file.into(), name: "Renamed".into() }, &|_| None).reply
}
#[test]
fn a_flood_of_changes_from_one_address_is_held_back_and_others_are_not() {
let dir = TempDir::new("admin-budget");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Poster", &snapshot("Poster")).unwrap();
let flooder = ip("198.51.100.1");
let start = std::time::Instant::now();
let done = (0..100).filter(|_| rename(&mut store, flooder, &file.id) == AdminReply::Done).count();
// The budget refills one request a second, so a slow run (a loaded test machine) lets a few more in.
let budget = ADMIN_PER_MINUTE as usize;
let refilled = start.elapsed().as_secs() as usize + 1;
assert!((budget..=budget + refilled).contains(&done), "a minute's worth, then wait: {done} done");
// Use up whatever refilled meanwhile; the request right after is refused.
for _ in 0..refilled {
if rename(&mut store, flooder, &file.id) != AdminReply::Done {
break;
}
}
assert_eq!(rename(&mut store, flooder, &file.id), AdminReply::Failed { reason: BUSY_ADMIN.into() });
// An owner token's requests share the address's budget.
let hosted = store.hosted(flooder, Some(&file.id), Admin::Rename { file: file.id.clone(), name: "Mine".into() }, &|_| None).reply;
assert_eq!(hosted, AdminReply::Failed { reason: BUSY_ADMIN.into() });
// Looking costs nothing, and another address has its own budget.
assert!(matches!(Backend::admin(&mut store, flooder, Admin::Files, &|_| None).reply, AdminReply::Files(_)));
assert_eq!(rename(&mut store, ip("198.51.100.2"), &file.id), AdminReply::Done);
}
#[test]
fn codes_are_found_through_the_index_as_before() {
let dir = TempDir::new("code-index");
let files_dir = dir.path().join("files");
let (files, codes) = {
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let mut files = Vec::new();
let mut codes = Vec::new();
for n in 0..12 {
let (file, edit, _view) = store.publish(&format!("File {n}"), &snapshot("File")).unwrap();
codes.push(store.set_code(&file.id, &edit.token).unwrap());
files.push((file.id, edit.token, edit.link.id));
}
(files, codes)
};
// A copy of a file's folder holds the same code (the last file by id wins, as before).
let copy = "zzzzzzzzzzzzzzzz";
std::fs::create_dir_all(files_dir.join(copy)).unwrap();
for f in std::fs::read_dir(files_dir.join(&files[0].0)).unwrap() {
let f = f.unwrap();
std::fs::copy(f.path(), files_dir.join(copy).join(f.file_name())).unwrap();
}
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let who = |n: usize| ip(&format!("192.0.2.{n}"));
for (n, ((id, token, _), code)) in files.iter().zip(&codes).enumerate().skip(1) {
assert_eq!(store.resolve_code(who(n), code).unwrap(), (id.clone(), token.clone()), "file {n}");
}
assert_eq!(store.resolve_code(who(100), &codes[0]).unwrap().0, copy);
// Revoking the copy's link leaves the original's code working; revoking that one ends it.
store.revoke(copy, &files[0].2).unwrap();
assert_eq!(store.resolve_code(who(101), &codes[0]).unwrap(), (files[0].0.clone(), files[0].1.clone()));
store.revoke(&files[0].0, &files[0].2).unwrap();
assert_eq!(store.resolve_code(who(102), &codes[0]).unwrap_err(), CODE_GONE);
// A new code replaces the old in the index; a removed file's code is gone.
let newer = store.set_code(&files[1].0, &files[1].1).unwrap();
assert_eq!(store.resolve_code(who(103), &codes[1]).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(who(104), &newer).unwrap().0, files[1].0);
store.remove(&files[2].0).unwrap();
assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE);
assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0);
}
+4 -3
View File
@@ -43,8 +43,8 @@ Options (also keys of the --config TOML file):
--data DIR the data directory (default ./figcraft-data)
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb, max_plugins,
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
max_connections, max_people, compact_after, compact_mb, max_log_mb,
max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish,
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
connects_per_minute, publishes_per_hour, messages_per_second
@@ -316,7 +316,8 @@ fn running(data: &Path) -> Result<SocketAddr, String> {
/// Do one request: through the running server, or on the data directory.
fn admin(cfg: &Config, req: Admin) -> Result<AdminReply, String> {
match Store::try_open(&cfg.data, cfg.store())? {
Some(mut store) => Ok(store.admin(req, &|_| None).reply),
// On this machine, one request per command: never over the address's budget.
Some(mut store) => Ok(store.admin(std::net::Ipv4Addr::LOCALHOST.into(), req, &|_| None).reply),
None => {
let addr = running(&cfg.data)?;
let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?;
+1 -15
View File
@@ -7,11 +7,9 @@
use std::net::IpAddr;
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::code;
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, Hello, OpenHosting, PROTOCOL, Resolve, ServerMsg};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::{Document, NodeKind};
use figcraft_engine::Session;
use figcraft_engine::live::Status;
use serde_json::{Value, json};
@@ -20,24 +18,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, W
use crate::codes::Key;
use crate::config::Config;
use crate::store::{CODE_GONE, CODES_BUSY, NO_CODE, Store};
use crate::test_util::{Raw, TempDir, config, live, pump, session};
use crate::test_util::{Raw, TempDir, config, ip, live, pump, session, snapshot};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
+14 -7
View File
@@ -39,6 +39,9 @@ pub struct Config {
pub compact_after: usize,
/// …or once the log is this large (MiB).
pub compact_mb: u64,
/// Most MiB of recent batches a file in use keeps in memory for apps that reconnect (one
/// further behind catches up with the whole file).
pub max_log_mb: u64,
/// Most plugin versions the plugin directory keeps (0 turns it off).
pub max_plugins: usize,
/// Largest plugin bundle, in MiB.
@@ -84,6 +87,7 @@ impl Default for Config {
max_people: 256,
compact_after: 1000,
compact_mb: 16,
max_log_mb: 64,
max_plugins: 500,
max_plugin_mb: 16,
max_plugins_mb: 1024,
@@ -209,6 +213,7 @@ impl Config {
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_log_mb", self.max_log_mb),
("max_plugin_mb", self.max_plugin_mb),
("max_plugins_mb", self.max_plugins_mb),
] {
@@ -216,7 +221,7 @@ impl Config {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.file_mb() > 4096 || self.compact_mb > 4096 {
if self.file_mb() > 4096 || self.compact_mb > 4096 || self.max_log_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
// A bundle travels in one message (at most 64 MiB) next to its entry.
@@ -274,17 +279,19 @@ impl Config {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
/// The collaboration core's part: when open, messages as large as a file at most, and the
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
/// quarter of a file's bytes (256 KiB at least).
/// The collaboration core's part: the batches kept for reconnecting clients (in memory, and
/// in the file's snapshot) at most `max_log_mb`; when open, messages as large as a file at
/// most, and those batches also at most a quarter of a file's bytes (256 KiB at least).
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX);
let base =
figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() };
if !self.open_publish {
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
let max = self.open_message();
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
let log = (bytes(self.file_mb()) / 4).max(256 << 10).min(base.max_log_bytes);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: log, ..base }
}
/// The transport's part. A private server keeps the defaults; open to the public, connections
+1 -13
View File
@@ -25,24 +25,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST};
use crate::config::Config;
use crate::public_tests::via_proxy;
use crate::store::{BUSY_DOWNLOADS, NO_PUBLIC_LIBRARY, Store};
use crate::test_util::{Raw, TempDir, config};
use crate::test_util::{Raw, TempDir, config, ip, snapshot};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "abuse@example.com".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
/// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot
/// (deflated: a few KiB) and `padding` incompressible bytes stored beside it.
fn archive(bomb: usize, padding: usize) -> Vec<u8> {
+2
View File
@@ -86,6 +86,8 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
Ok(Running { hosting })
}
#[cfg(test)]
mod admin_budget_tests;
#[cfg(test)]
mod background_tests;
#[cfg(test)]
+14 -15
View File
@@ -6,10 +6,8 @@ use std::io::{Read, Write};
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ServerMsg};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::{Document, NodeKind};
use serde_json::{Value, json};
use tungstenite::client::IntoClientRequest;
use tungstenite::{Bytes, Message};
@@ -18,24 +16,12 @@ use crate::abuse::{Abuse, AbuseConfig, Action, MAX_ADDRESSES};
use crate::cli::{self, Cli, Command, Sort};
use crate::config::Config;
use crate::store::{KEY_ONLY, Store};
use crate::test_util::{Raw, TempDir, config, join, key, live, pump, session};
use crate::test_util::{Raw, TempDir, config, ip, join, key, live, pump, session, snapshot};
fn open(dir: &std::path::Path) -> Config {
Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
}
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = Document::new(name);
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
Replica::new(d).snapshot().unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
@@ -584,6 +570,19 @@ fn files_above_64_mib_fit_every_limit() {
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
}
#[test]
fn the_batches_kept_for_reconnecting_apps_follow_max_log_mb() {
let log = |cfg: Config| cfg.store().core.max_log_bytes;
assert_eq!(log(Config::default()), 64 << 20, "a private server's log is bounded too");
assert_eq!(log(Config { max_log_mb: 8, ..Config::default() }), 8 << 20);
// Open to the public: also at most a quarter of a file (16 MiB unless set).
assert_eq!(log(Config { open_publish: true, ..Config::default() }), 4 << 20);
assert_eq!(log(Config { open_publish: true, max_log_mb: 1, ..Config::default() }), 1 << 20);
for mb in [0, 4097] {
assert!(Config { max_log_mb: mb, ..Config::default() }.check().is_err(), "max_log_mb = {mb}");
}
}
#[test]
fn large_files_warn_the_operator() {
let public_url = Some("wss://figcraft.example.com/".into());
+11 -1
View File
@@ -30,7 +30,7 @@
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
//! never deleted.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
use std::fs::{self, File, OpenOptions, TryLockError};
use std::io::{Cursor, Write};
use std::net::IpAddr;
@@ -88,6 +88,9 @@ const MAX_PROBLEMS: usize = 200;
/// Why a download is turned away (an address's share, or everyone's, is used up for now).
pub const BUSY_DOWNLOADS: &str = "too many downloads from your address or from everyone right now: try again in a minute";
/// Why a request that changes the server's files is turned away (the address's share is used up).
pub const BUSY_ADMIN: &str = "too many changes to this server's files from your address: wait a minute and try again";
/// Why publishing without the key is refused on a server that isn't open to the public.
pub const KEY_ONLY: &str = "this server takes files only from people who have its key: add ?key=… to the address (its operator has the key)";
@@ -270,6 +273,9 @@ pub struct Store {
// shortcut: in memory only, so after a restart such a code reads as unknown; keep them in the
// data directory if people ask why their code "never existed".
gone_codes: VecDeque<String>,
/// The files whose links hold each join code, by the code's hash ([`Sealed::hash`]): a typed
/// code is looked up here instead of against every link.
codes: HashMap<String, BTreeSet<String>>,
/// When idle files are looked for next (seconds since 1970).
next_sweep: u64,
/// Compactions so far (tests count them).
@@ -396,6 +402,7 @@ impl Store {
blocked: BTreeSet::new(),
reports: Vec::new(),
gone_codes: VecDeque::new(),
codes: HashMap::new(),
next_sweep: 0,
compactions: 0,
cut_short: Vec::new(),
@@ -448,6 +455,9 @@ impl Store {
}
match self.check(&path) {
Ok(entry) => {
for c in entry.links.iter().filter_map(|l| l.code.as_ref()) {
self.codes.entry(c.hash.clone()).or_default().insert(id.clone());
}
self.files.insert(id, entry);
}
Err(why) => self.note(format!("skipped file {id}: {why}")),
+17 -4
View File
@@ -1,9 +1,18 @@
use super::journal::flush_log;
use super::{
Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply,
LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr,
LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
};
/// Whether `req` changes the server's files (a write, a removal or a join code's stretching):
/// such requests count against the address's budget ([`Action::Admin`]); looking doesn't.
fn changes_files(req: &Admin) -> bool {
!matches!(
req,
Admin::Files | Admin::Links { .. } | Admin::Listing | Admin::Reports | Admin::Blocked | Admin::Owned { .. } | Admin::RemovePeer { .. }
)
}
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
let entry = self.entry(file)?;
@@ -156,15 +165,19 @@ impl Backend for Store {
if owns != Some(file.as_str()) {
return failed("an owner token manages only its own file");
}
self.admin(req, open)
self.admin(ip, req, open)
}
fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
self.keep_report(file, link, ip, reason)
}
fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
fn admin(&mut self, ip: IpAddr, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, Instant::now()) {
log::info!("event=refused ip={ip} reason=admin_per_address");
return failed(BUSY_ADMIN.into());
}
match req {
Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
+22 -8
View File
@@ -44,18 +44,30 @@ impl Store {
found
}
/// These join codes no longer open anything: typing one says so.
fn forget_codes(&mut self, codes: impl IntoIterator<Item = Sealed>) {
self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
/// These join codes of `file` no longer open anything: typing one says so. A code is dropped
/// from the index once none of `file`'s links holds it.
fn forget_codes(&mut self, file: &str, codes: impl IntoIterator<Item = Sealed>) {
for c in codes {
let held = self.files.get(file).is_some_and(|e| e.links.iter().any(|l| l.code.as_ref().is_some_and(|k| k.hash == c.hash)));
if !held && let Some(files) = self.codes.get_mut(&c.hash) {
files.remove(file);
if files.is_empty() {
self.codes.remove(&c.hash);
}
}
self.gone_codes.push_back(c.hash);
}
let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
self.gone_codes.drain(..extra);
}
/// Where the link with the join code `key` is: its file, its place among the file's links
/// and its token (every code is compared, so timing doesn't tell which one matched).
/// and its token. Only the files the index names for the code's hash are looked at; their
/// links are each compared, in constant time.
fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
let mut found = None;
for (id, e) in &self.files {
for id in self.codes.get(key.hash())? {
let Some(e) = self.files.get(id) else { continue };
for (i, l) in e.links.iter().enumerate() {
if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
found = Some((id.clone(), i, token));
@@ -78,6 +90,7 @@ impl Store {
}
};
let sealed = key.seal(token)?;
let found_by = sealed.hash.clone();
let path = self.dir(file).join(LINKS);
let entry = self.entry(file)?;
let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
@@ -89,7 +102,8 @@ impl Store {
}
return Err(format!("couldn't keep the join code: {e}"));
}
self.forget_codes(before);
self.codes.entry(found_by).or_default().insert(file.to_string());
self.forget_codes(file, before);
log::info!("event=code file={file} link={id}");
Ok(code)
}
@@ -137,7 +151,7 @@ impl Store {
return Err(format!("couldn't revoke the link: {e}"));
}
log::info!("revoked link {link} of file {file}");
self.forget_codes(gone.code);
self.forget_codes(file, gone.code);
Ok(())
}
@@ -146,7 +160,7 @@ impl Store {
pub fn remove(&mut self, file: &str) -> Result<(), String> {
// Closed first: an open log would keep the folder from being deleted on Windows.
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
self.forget_codes(file, entry.links.iter().filter_map(|l| l.code.clone()));
drop(entry);
let dir = self.dir(file);
fs::remove_dir_all(&dir)
+14
View File
@@ -170,3 +170,17 @@ impl Raw {
}
}
}
/// A one-page file with one rectangle, named `name`, as a snapshot to publish.
pub fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
let mut d = figcraft_doc::Document::new(name);
let page = d.first_page().unwrap();
d.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
d.repair();
figcraft_collab::Replica::new(d).snapshot().unwrap()
}
/// An address from text.
pub fn ip(s: &str) -> std::net::IpAddr {
s.parse().unwrap()
}
+3 -2
View File
@@ -17,15 +17,16 @@ path = "src/main.rs"
figcraft-engine = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
base64 = { workspace = true }
serde_json = { workspace = true }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
+39
View File
@@ -0,0 +1,39 @@
//! What the page keeps across reloads, in the browser's local storage: your name (Preferences ▸
//! Your name), so a reload or a later visit joins live sessions and comments as you.
use figcraft_engine::Session;
use serde_json::json;
/// Put back the name the page kept, checked as one typed in Preferences: the page's
/// storage is anyone's to change.
pub fn restore_name(session: &mut Session, kept: Option<String>) {
if let Some(name) = kept
&& let Err(e) = session.execute("pref.userName", &json!({"name": name}))
{
log::warn!("the kept name wasn't used: {e}");
}
}
#[cfg(test)]
mod tests {
use figcraft_engine::Session;
use serde_json::json;
use super::restore_name;
/// What one page kept, the next one restores; junk in the storage is cleaned like typing.
#[test]
fn the_name_survives_a_reload() {
let mut before = Session::new();
before.execute("pref.userName", &json!({"name": " Alice "})).unwrap();
let kept = before.prefs.user_name.clone();
let mut after = Session::new();
restore_name(&mut after, Some(kept));
assert_eq!(after.prefs.user_name, "Alice");
let mut fresh = Session::new();
restore_name(&mut fresh, None);
assert_eq!(fresh.prefs.user_name, Session::new().prefs.user_name, "nothing kept: the default");
restore_name(&mut fresh, Some(format!("Bob\u{7}{}", "x".repeat(500))));
assert!(fresh.prefs.user_name.starts_with("Bobx") && fresh.prefs.user_name.chars().count() == 64);
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Fonts installed on this computer, through the browser's Local Font Access API
//! (`window.queryLocalFonts`, Chromium): asked for when the person clicks (the browser asks them
//! for permission), then known by name in the font list. A font's bytes are read only when text
//! uses it: `figcraft_text` asks for them ([`load_wanted`], each frame) and takes them when they
//! come; auto-sizing text then fits the real font.
use std::cell::{Cell, RefCell};
use figcraft_text::LocalFont;
use js_sys::{Array, Function, Promise, Reflect, Uint8Array};
use wasm_bindgen::{JsCast as _, JsValue};
use wasm_bindgen_futures::JsFuture;
/// Most fonts taken from the browser's list (a computer with a font manager can have tens of
/// thousands; each is only a name here until it is used).
const MAX_FONTS: usize = 20_000;
thread_local! {
/// The browser's `FontData` of each font offered, by its number.
static FONTS: RefCell<Vec<JsValue>> = const { RefCell::new(Vec::new()) };
/// Asked for already (the browser asks the person once; their answer stands for the page).
static ASKED: Cell<bool> = const { Cell::new(false) };
/// A font's bytes came since the last frame: auto-sizing text fits them.
static LOADED: Cell<bool> = const { Cell::new(false) };
}
/// The browser can list the fonts installed on this computer, and the page hasn't asked yet.
pub fn can_ask() -> bool {
!ASKED.with(Cell::get) && web_sys::window().is_some_and(|w| Reflect::has(&w, &"queryLocalFonts".into()).unwrap_or(false))
}
/// Ask the browser for the installed fonts (it asks the person); `ctx` repaints when they're in.
pub fn ask(ctx: egui::Context) {
ASKED.with(|a| a.set(true));
wasm_bindgen_futures::spawn_local(async move {
match query().await {
Ok(n) => log::info!("fonts on this computer: {n} new families"),
Err(e) => log::warn!("the browser didn't list the fonts on this computer: {e:?}"),
}
ctx.request_repaint();
});
}
/// A `FontData`'s text field.
fn text(font: &JsValue, key: &str) -> String {
Reflect::get(font, &key.into()).ok().and_then(|v| v.as_string()).unwrap_or_default()
}
/// Call `obj.name()`, a method returning a promise, and wait for it.
async fn call(obj: &JsValue, name: &str) -> Result<JsValue, JsValue> {
let f: Function = Reflect::get(obj, &name.into())?.dyn_into()?;
JsFuture::from(f.call0(obj)?.dyn_into::<Promise>()?).await
}
/// The browser's font list, offered to the font database by name: the number of new families.
async fn query() -> Result<usize, JsValue> {
let window: JsValue = web_sys::window().ok_or("no window")?.into();
let list: Array = call(&window, "queryLocalFonts").await?.dyn_into()?;
let fonts = FONTS.with(|all| {
let mut all = all.borrow_mut();
list.iter()
.take(MAX_FONTS.saturating_sub(all.len()))
.map(|font| {
let f = LocalFont {
id: all.len(),
family: text(&font, "family"),
style: text(&font, "style"),
postscript: text(&font, "postscriptName"),
};
all.push(font);
f
})
.collect()
});
Ok(figcraft_text::add_local_fonts(fonts).len())
}
/// Read the fonts text asked for since the last frame; `ctx` repaints as each comes.
pub fn load_wanted(ctx: &egui::Context) {
for id in figcraft_text::take_wanted_local_fonts() {
let Some(font) = FONTS.with(|f| f.borrow().get(id).cloned()) else { continue };
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
// A font that can't be read is dropped (its text keeps the fallback).
let bytes = read(&font).await.unwrap_or_else(|e| {
log::warn!("couldn't read a font from this computer: {e:?}");
Vec::new()
});
if figcraft_text::load_local_font(id, bytes).is_some() {
LOADED.with(|l| l.set(true));
}
ctx.request_repaint();
});
}
}
/// A `FontData`'s file, at most [`figcraft_text::fontdb::MAX_FONT_FILE`] bytes.
async fn read(font: &JsValue) -> Result<Vec<u8>, JsValue> {
let blob: web_sys::Blob = call(font, "blob").await?.dyn_into()?;
if blob.size() > figcraft_text::fontdb::MAX_FONT_FILE as f64 {
return Err("the font file is too large".into());
}
let buf = JsFuture::from(blob.array_buffer()).await?;
Ok(Uint8Array::new(&buf).to_vec())
}
/// A font's bytes came since the last call.
pub fn take_loaded() -> bool {
LOADED.with(|l| l.replace(false))
}
+13 -4
View File
@@ -8,10 +8,15 @@
//! - 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;
//! - 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.
//! - of the preferences, the page keeps only your name across reloads (`kept.rs`);
//! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this
//! computer…", Chromium's Local Font Access) the computer's, each read when text uses it
//! (`local_fonts.rs`);
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) and files on
//! a `figcraft-server`, and publishes files to a server (Share ▸ Online, Share ▸ Publish to
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
@@ -24,6 +29,10 @@
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod kept;
#[cfg(target_arch = "wasm32")]
mod local_fonts;
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
+38 -2
View File
@@ -6,6 +6,8 @@ use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
const LOADING_ID: &str = "figcraft_loading";
/// The local storage key your name is kept under ([`crate::kept`]).
const NAME_KEY: &str = "figcraft.userName";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
@@ -36,8 +38,9 @@ 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: the browser joins sessions a desktop app hosts; whatever arrives
// on the socket wakes the page for a frame.
crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten()));
// Live sessions: the browser joins sessions and publishes files to servers;
// 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 mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
@@ -67,6 +70,11 @@ pub fn start() {
});
}
/// The page's local storage (`None` where the browser keeps none, e.g. storage turned off).
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
@@ -85,10 +93,25 @@ impl eframe::App for WebShell {
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// Pointer and key events sent over the control channel (`ui.input`), for the frame about
// to draw: the batch is applied by this frame and acknowledged once it is presented, as on
// the desktop (`apps/figcraft/src/main.rs`). Without this hook the batch sat in its queue
// and its promise never settled.
let events = figcraft_ui_egui::windows::take_input(&mut self.fig, raw_input.viewport_id);
raw_input.events.extend(events);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.control_queue.extend(crate::api::take_requests());
self.fig.ui(ui);
crate::api::poll_replies();
// The fonts on this computer that text asked for this frame are read; once one is in,
// auto-sizing text measured in a fallback fits it.
crate::local_fonts::load_wanted(ui.ctx());
if crate::local_fonts::take_loaded() {
self.fig.run("text.refit", serde_json::json!({}));
}
}
}
@@ -144,6 +167,12 @@ impl Services for WebServices {
fn reduce_motion(&self) -> bool {
self.reduce_motion.as_ref().is_some_and(web_sys::MediaQueryList::matches)
}
/// Only your name is kept ([`crate::kept`]): the rest starts afresh with each page.
fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) {
if let Some(Err(e)) = local_storage().map(|s| s.set_item(NAME_KEY, &prefs.user_name)) {
log::warn!("couldn't keep your name: {e:?}");
}
}
/// The browser's language (`navigator.language`).
fn system_language(&self) -> Option<String> {
web_sys::window().and_then(|w| w.navigator().language())
@@ -165,6 +194,13 @@ impl Services for WebServices {
}
held
}
/// Local Font Access (`window.queryLocalFonts`, Chromium), not asked yet.
fn can_ask_for_local_fonts(&self) -> bool {
crate::local_fonts::can_ask()
}
fn ask_for_local_fonts(&mut self) {
crate::local_fonts::ask(self.ctx.clone());
}
}
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
+2
View File
@@ -20,6 +20,8 @@ figcraft-text = { workspace = true }
# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
craft-window = { workspace = true }
# The suite's settings folder: `FIGCRAFT_CONFIG_DIR`, a portable marker, then the platform folder.
craft-settings = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
serde_json = { workspace = true }
+14 -2
View File
@@ -59,8 +59,20 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.extend(queue.drain(..count));
}
pub fn start(port: u16, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
/// Bind `port`, waiting up to `busy` for it while another process holds it: an app started again on
/// other graphics ([`crate::graphics`]) starts beside the one ending, which still holds it.
pub fn bind(port: u16, busy: Duration) -> std::io::Result<TcpListener> {
let since = std::time::Instant::now();
loop {
match TcpListener::bind(("127.0.0.1", port)) {
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && since.elapsed() < busy => std::thread::sleep(Duration::from_millis(50)),
bound => return bound,
}
}
}
pub fn start(port: u16, busy: Duration, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>) {
let listener = match bind(port, busy) {
Ok(l) => l,
Err(e) => {
log::error!("control server: can't bind 127.0.0.1:{port}: {e} (another app may own it; pick another port)");
+164 -15
View File
@@ -1,4 +1,4 @@
//! Which graphics API draws the window.
//! Which graphics API draws the window, and what happens when it can't.
//!
//! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan
//! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens:
@@ -6,29 +6,178 @@
//! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows
//! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver.
//!
//! Two shader compilers lost the device before the window opened ("Parent device is lost", #965,
//! #1438), both on wgpu's indirect-call validation shaders, which wgpu compiles with every device:
//! Direct3D 12 took the first `dxcompiler.dll` on the search path, another program's, and failed
//! on the missing `dxil.dll` beside it; Metal's Intel compiler aborts on them on older Macs. So
//! Direct3D 12 compiles with FXC, which is part of Windows, and the validation shaders are left
//! out: FigCraft draws nothing indirectly, so they would check nothing.
//!
//! When the graphics still fail before the window's first frame, the app starts again with the
//! next of its [`attempts`]: on Windows Vulkan, then OpenGL; elsewhere OpenGL; and last the first
//! backends again with lower requirements on the power-saving GPU (macOS has no OpenGL in wgpu, so
//! that is its only other try). The attempts are a fixed list, so this ends; the message the last
//! one shows names what was tried.
//!
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
//! systems other than Windows keep eframe's defaults.
//! is then the one attempt. `WGPU_DX12_COMPILER` and `WGPU_VALIDATION_INDIRECT_CALL` still choose
//! the compiler and the validation.
use std::sync::Arc;
use std::time::Duration;
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
use eframe::wgpu::Backends;
use eframe::wgpu::{self, Backends, Dx12Compiler, InstanceFlags, Limits, PowerPreference};
/// Which of the [`attempts`] this start is: set by the app itself when it starts again.
pub const ATTEMPT_ENV: &str = "FIGCRAFT_GRAPHICS_ATTEMPT";
/// Frames drawn before a failure below which the window was still starting up: the second frame
/// begins once the first was presented.
pub const STARTUP_FRAMES: u64 = 2;
/// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one.
const WINDOWS: Backends = Backends::DX12.union(Backends::GL);
/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`]
/// on Windows, else eframe's `defaults`.
pub fn backends(requested: Option<Backends>, windows: bool, defaults: Backends) -> Backends {
match requested {
Some(requested) => requested,
None if windows => WINDOWS,
None => defaults,
/// The graphics APIs by name, in the order the startup message lists them.
const NAMES: [(Backends, &str); 4] =
[(Backends::DX12, "Direct3D 12"), (Backends::METAL, "Metal"), (Backends::VULKAN, "Vulkan"), (Backends::GL, "OpenGL")];
/// One way of starting the graphics.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Attempt {
/// The backends wgpu chooses from.
pub backends: Backends,
/// The power-saving GPU and only the device limits OpenGL ES 3 hardware has.
pub lower: bool,
}
/// The ways to start, in order: only `WGPU_BACKEND`'s backends (`requested`) when it is set;
/// otherwise [`WINDOWS`] on Windows, else eframe's `defaults`, then Vulkan on Windows, OpenGL
/// except on macOS, and those first backends again with lower requirements.
pub fn attempts(requested: Option<Backends>, windows: bool, macos: bool, defaults: Backends) -> Vec<Attempt> {
let normal = |backends| Attempt { backends, lower: false };
if let Some(requested) = requested {
return vec![normal(requested)];
}
let first = if windows { WINDOWS } else { defaults };
let mut list = vec![normal(first)];
if windows {
list.push(normal(Backends::VULKAN));
}
if !macos && first != Backends::GL {
list.push(normal(Backends::GL));
}
list.push(Attempt { backends: first, lower: true });
list
}
/// Which of `count` attempts this start is, from [`ATTEMPT_ENV`] (`value`): the first when it is
/// unset or not a number, the last when it is past the end.
pub fn current(value: Option<&str>, count: usize) -> usize {
value.and_then(|v| v.trim().parse().ok()).unwrap_or(0).min(count.saturating_sub(1))
}
/// The attempt to start again with after `attempt` of `count` ended: the next one, when the
/// graphics failed (`graphics_failed`) before the window's first frame and one is left.
pub fn next(attempt: usize, count: usize, graphics_failed: bool, frames: u64) -> Option<usize> {
let next = attempt + 1;
(graphics_failed && frames < STARTUP_FRAMES && next < count).then_some(next)
}
/// How long this start waits for its control port: one started again ([`ATTEMPT_ENV`]) starts
/// beside the app that is ending, which still holds it for a moment.
pub fn port_wait() -> Duration {
if std::env::var_os(ATTEMPT_ENV).is_some() { Duration::from_secs(5) } else { Duration::ZERO }
}
/// The graphics APIs `attempts` tried among those this build has (`built`), by name.
pub fn names(attempts: &[Attempt], built: Backends) -> String {
let tried = attempts.iter().fold(Backends::empty(), |all, a| all | a.backends) & built;
NAMES.iter().filter(|(b, _)| tried.contains(*b)).map(|(_, name)| *name).collect::<Vec<_>>().join(", ")
}
/// Set up `options` for `attempt` (see the module docs).
pub fn apply(options: &mut WgpuConfiguration, attempt: Attempt) {
let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup else { return };
let instance = &mut create.instance_descriptor;
instance.backends = attempt.backends;
instance.backend_options.dx12.shader_compiler = Dx12Compiler::from_env().unwrap_or(Dx12Compiler::Fxc);
instance.flags = instance.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
if attempt.lower {
create.power_preference = PowerPreference::from_env().unwrap_or(PowerPreference::LowPower);
create.device_descriptor = Arc::new(|adapter| wgpu::DeviceDescriptor {
label: Some("egui wgpu device"),
// The window's surface still needs textures as large as the adapter allows.
required_limits: Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()),
..Default::default()
});
}
}
/// Offer wgpu [`backends`] instead of eframe's defaults.
pub fn configure(options: &mut WgpuConfiguration) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup {
let offered = &mut create.instance_descriptor.backends;
*offered = backends(Backends::from_env(), cfg!(windows), *offered);
/// The attempts of this run and which one it is.
pub struct Plan {
attempts: Vec<Attempt>,
current: usize,
}
impl Plan {
/// Set up `options` for this start's attempt.
pub fn configure(options: &mut WgpuConfiguration) -> Self {
let defaults = match &options.wgpu_setup {
WgpuSetup::CreateNew(create) => create.instance_descriptor.backends,
WgpuSetup::Existing(_) => Backends::all(),
};
let attempts = attempts(Backends::from_env(), cfg!(windows), cfg!(target_os = "macos"), defaults);
let current = current(std::env::var(ATTEMPT_ENV).ok().as_deref(), attempts.len());
if let Some(attempt) = attempts.get(current) {
apply(options, *attempt);
}
Self { attempts, current }
}
/// The window opened on `adapter`: which GPU draws is the first question in every report.
pub fn opened(&self, adapter: &wgpu::AdapterInfo) {
let text =
format!("graphics: drawing with {:?} on {} ({:?}, driver {})", adapter.backend, adapter.name.trim(), adapter.device_type, adapter.driver);
if self.current == 0 {
log::info!("{text}");
} else {
log::warn!("{text}, start {} of {}: {:?}", self.current + 1, self.attempts.len(), self.attempts.get(self.current));
}
}
/// After the window's run (`result`, after `frames` frames): start again with the next attempt
/// when the graphics failed while the window was starting up, otherwise say why it failed
/// ([`crate::startup::failed`], `quiet` as there).
pub fn finish(&self, result: eframe::Result, frames: u64, quiet: bool) -> eframe::Result {
let Err(error) = &result else { return result };
if let Some(next) = next(self.current, self.attempts.len(), matches!(error, eframe::Error::Wgpu(_)), frames) {
log::warn!("graphics: {error}; starting again with {:?}", self.attempts.get(next));
match restart(next) {
Ok(()) => return Ok(()),
Err(e) => log::error!("graphics: starting again failed: {e}"),
}
}
let last = self.current > 0 && self.current + 1 == self.attempts.len();
let tried = last.then(|| names(&self.attempts, wgpu::Instance::enabled_backend_features()));
crate::startup::failed(error, quiet, tried.as_deref());
result
}
}
/// Start the app again with the same arguments as attempt `attempt`. On Unix the new app replaces
/// this process (an AppImage keeps its files mounted), and this returns only on failure; elsewhere
/// it starts beside this one, which then exits.
fn restart(attempt: usize) -> std::io::Result<()> {
log::logger().flush();
let mut command = std::process::Command::new(std::env::current_exe()?);
command.args(std::env::args_os().skip(1)).env(ATTEMPT_ENV, attempt.to_string());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
Err(command.exec())
}
#[cfg(not(unix))]
command.spawn().map(|_| ())
}
+132 -16
View File
@@ -1,32 +1,148 @@
//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan
//! driver crashed the app as its window opened), and nothing changed anywhere else.
//! The graphics attempts: no Vulkan on Windows at first (a faulting Vulkan driver crashed the app
//! as its window opened), another backend and then lower requirements when the graphics fail
//! before the first frame (#965, #1438), and never more starts than attempts.
use eframe::wgpu::Backends;
use std::time::Duration;
use crate::graphics::backends;
use eframe::egui_wgpu::{WgpuConfiguration, WgpuError, WgpuSetup, WgpuSetupCreateNew};
use eframe::wgpu::{Backends, Dx12Compiler, InstanceFlags, PowerPreference};
use crate::control_server::bind;
use crate::graphics::{ATTEMPT_ENV, Attempt, Plan, STARTUP_FRAMES, apply, attempts, current, names, next, port_wait};
/// What eframe offers when `WGPU_BACKEND` isn't set.
const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL);
#[test]
fn windows_is_offered_direct3d_and_not_vulkan() {
let offered = backends(None, true, DEFAULTS);
assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window");
assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver");
assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked");
const DX12_GL: Backends = Backends::DX12.union(Backends::GL);
const fn normal(backends: Backends) -> Attempt {
Attempt { backends, lower: false }
}
const fn lower(backends: Backends) -> Attempt {
Attempt { backends, lower: true }
}
#[test]
fn wgpu_backend_names_the_backend_on_every_system() {
for windows in [true, false] {
fn windows_starts_on_direct3d_then_vulkan_then_opengl_then_lower_requirements() {
let list = attempts(None, true, false, DEFAULTS);
assert_eq!(list, [normal(DX12_GL), normal(Backends::VULKAN), normal(Backends::GL), lower(DX12_GL)]);
assert!(!list[0].backends.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked at first");
}
#[test]
fn linux_falls_back_to_opengl_then_lower_requirements() {
assert_eq!(attempts(None, false, false, DEFAULTS), [normal(DEFAULTS), normal(Backends::GL), lower(DEFAULTS)]);
// OpenGL alone is tried once.
assert_eq!(attempts(None, false, false, Backends::GL), [normal(Backends::GL), lower(Backends::GL)]);
}
#[test]
fn macos_has_no_opengl_so_it_only_lowers_the_requirements() {
assert_eq!(attempts(None, false, true, DEFAULTS), [normal(DEFAULTS), lower(DEFAULTS)]);
assert_eq!(attempts(None, false, true, Backends::METAL), [normal(Backends::METAL), lower(Backends::METAL)]);
}
#[test]
fn wgpu_backend_names_the_one_attempt_on_every_system() {
for (windows, macos) in [(true, false), (false, false), (false, true)] {
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
assert_eq!(attempts(Some(asked), windows, macos, DEFAULTS), [normal(asked)], "windows: {windows}, macos: {macos}");
}
}
}
#[test]
fn other_systems_keep_eframes_defaults() {
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
fn the_attempt_comes_from_the_restart_and_stays_in_range() {
assert_eq!(current(None, 4), 0);
assert_eq!(current(Some("2"), 4), 2);
assert_eq!(current(Some(" 1 "), 4), 1);
assert_eq!(current(Some("9"), 4), 3, "past the end is the last");
assert_eq!(current(Some("-1"), 4), 0);
assert_eq!(current(Some("junk"), 4), 0);
assert_eq!(current(Some("1"), 0), 0);
}
#[test]
fn only_a_graphics_failure_while_starting_up_starts_again_and_never_past_the_last_attempt() {
assert_eq!(next(0, 4, true, 0), Some(1));
assert_eq!(next(2, 4, true, STARTUP_FRAMES - 1), Some(3));
assert_eq!(next(3, 4, true, 0), None, "the last attempt ends it");
assert_eq!(next(0, 1, true, 0), None, "WGPU_BACKEND's one attempt ends it");
assert_eq!(next(0, 4, false, 0), None, "not the graphics");
assert_eq!(next(0, 4, true, STARTUP_FRAMES), None, "the window ran");
// Following the restarts from the first start ends within the list.
for count in 1..6 {
let starts = std::iter::successors(Some(0), |&a| next(a, count, true, 0)).count();
assert_eq!(starts, count);
}
}
#[test]
fn the_message_names_the_apis_this_build_has() {
let windows = attempts(None, true, false, DEFAULTS);
assert_eq!(names(&windows, Backends::all()), "Direct3D 12, Vulkan, OpenGL");
let linux = attempts(None, false, false, DEFAULTS);
assert_eq!(names(&linux, Backends::VULKAN | Backends::GL), "Vulkan, OpenGL");
let macos = attempts(None, false, true, DEFAULTS);
assert_eq!(names(&macos, Backends::METAL), "Metal");
}
fn create_new(options: &WgpuConfiguration) -> Option<&WgpuSetupCreateNew> {
match &options.wgpu_setup {
WgpuSetup::CreateNew(create) => Some(create),
WgpuSetup::Existing(_) => None,
}
}
#[test]
fn every_attempt_compiles_with_fxc_and_leaves_the_indirect_validation_out() {
for attempt in [normal(Backends::DX12), lower(Backends::METAL)] {
let mut options = WgpuConfiguration::default();
let power = create_new(&options).map(|c| c.power_preference);
apply(&mut options, attempt);
let create = create_new(&options).expect("eframe creates the instance");
assert_eq!(create.instance_descriptor.backends, attempt.backends);
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
assert!(!create.instance_descriptor.flags.contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
}
if std::env::var_os("WGPU_POWER_PREF").is_none() {
let want = if attempt.lower { Some(PowerPreference::LowPower) } else { power };
assert_eq!(Some(create.power_preference), want);
}
}
}
#[test]
fn a_start_again_waits_for_the_control_port_the_ending_app_still_holds() {
let held = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("a free port");
let port = held.local_addr().expect("its address").port();
let busy = bind(port, Duration::ZERO).expect_err("the port is held");
assert_eq!(busy.kind(), std::io::ErrorKind::AddrInUse);
let release = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
drop(held);
});
assert!(bind(port, Duration::from_secs(10)).is_ok(), "bound once the other app let go");
release.join().expect("released");
if std::env::var_os(ATTEMPT_ENV).is_none() {
assert_eq!(port_wait(), Duration::ZERO, "a first start doesn't wait");
}
}
#[test]
fn a_failure_that_starts_nothing_again_ends_with_the_error() {
if std::env::var_os(ATTEMPT_ENV).is_some() {
return;
}
let mut options = WgpuConfiguration::default();
let plan = Plan::configure(&mut options);
assert!(plan.finish(Ok(()), 0, true).is_ok());
// A failure that isn't the graphics', and a graphics failure once the window ran: no restart.
assert!(plan.finish(Err(eframe::Error::AppCreation("no app".into())), 0, true).is_err());
let graphics = eframe::Error::Wgpu(WgpuError::NoSurfaceFormatsAvailable);
assert!(plan.finish(Err(graphics), STARTUP_FRAMES, true).is_err());
}
+14 -8
View File
@@ -43,6 +43,8 @@ mod recovery_tests;
#[cfg(test)]
mod services_tests;
#[cfg(test)]
mod settings_dir_tests;
#[cfg(test)]
mod startup_tests;
use std::sync::{Arc, Mutex};
@@ -234,7 +236,7 @@ fn main() -> eframe::Result {
}
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(), fig.session.events.clone());
control_server::start(p, graphics::port_wait(), queue.clone(), ctx_slot.clone(), fig.session.events.clone());
}
let icon = services::window_icon();
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
@@ -258,7 +260,9 @@ fn main() -> eframe::Result {
viewport = viewport.with_icon(icon);
}
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
let graphics = graphics::Plan::configure(&mut options.wgpu_options);
// Counts the frames drawn, which tells a window that failed starting up from one that ran.
let window = ctx_slot.clone();
figcraft_ui_egui::windows::install(fig);
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
@@ -269,7 +273,10 @@ fn main() -> eframe::Result {
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |cc| {
Box::new(|cc| {
if let Some(state) = &cc.wgpu_render_state {
graphics.opened(&state.adapter.get_info());
}
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
// winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the
// app, so unsaved changes are lost with no warning (issue #857). This menu's Quit
@@ -290,11 +297,10 @@ fn main() -> eframe::Result {
}))
}),
);
// Say why instead of quitting silently: release builds on Windows have no console.
if let Err(e) = &result {
startup::failed(e, port.is_some());
}
result
let frames = window.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().map_or(0, egui::Context::cumulative_frame_nr);
// Start again on other graphics, or say why instead of quitting silently: release builds on
// Windows have no console.
graphics.finish(result, frames, port.is_some())
}
/// With nothing to open, whether the window starts in a new file rather than on the home screen.
+43 -1
View File
@@ -27,14 +27,56 @@ pub struct Desktop {
last_folder: Arc<Mutex<Option<PathBuf>>>,
}
/// The settings folder's name in the platform folder, as it always was (the suite keeps existing
/// folder names), and craft-settings' app id: `FIGCRAFT_CONFIG_DIR` names another folder.
const APP_ID: &str = "FigCraft";
/// Where the preferences, recovery copies and installed plugins live ([`settings_dir`]).
pub fn config_dir() -> Option<PathBuf> {
settings_dir(&craft_settings::Probe { platform_config_dir: platform_dir(), ..Default::default() })
}
/// The platform's config folder, read from the environment as before: `%APPDATA%` on Windows,
/// so a test's own `APPDATA` keeps the app away from the user's settings.
fn platform_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
let base = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support"));
#[cfg(target_os = "windows")]
let base = std::env::var_os("APPDATA").map(PathBuf::from);
#[cfg(all(unix, not(target_os = "macos")))]
let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")));
base.map(|b| b.join("FigCraft"))
base
}
/// The settings folder by the suite's rules (craft-settings), from what `probe` sees:
/// `FIGCRAFT_CONFIG_DIR` (set but empty: none, nothing read or written); a `portable` file next
/// to the executable (next to the `.app` on macOS), which puts them in `config/` beside it; else
/// `FigCraft` in the platform folder, where they always were. A portable folder without
/// preferences yet starts with a copy of the platform folder's, so an existing copy that becomes
/// portable keeps its settings; the original stays for an installed app. No platform folder: none.
pub(crate) fn settings_dir(probe: &craft_settings::Probe) -> Option<PathBuf> {
use craft_settings::{Location, Portable, Rule};
let resolved = craft_settings::paths::resolve(APP_ID, &Portable::DEFAULT, &Location::Auto, probe);
match (&resolved.rule, &resolved.dir, &probe.platform_config_dir) {
(Rule::Portable, Some(dir), Some(platform)) => seed(dir, &platform.join(APP_ID)),
// The current directory instead: settings would follow wherever the app was started.
(Rule::Fallback, ..) => return None,
_ => {}
}
resolved.dir
}
/// Copy `from`'s preferences into the folder `to` when it has none (owner-only, as written).
fn seed(to: &Path, from: &Path) {
let (target, source) = (to.join(STATE), from.join(STATE));
if target.exists() || !source.is_file() {
return;
}
let copied = std::fs::read(&source).and_then(|bytes| std::fs::create_dir_all(to).and_then(|()| write_private(&target, &bytes)));
match copied {
Ok(()) => log::info!("preferences: copied {} into the portable folder {}", source.display(), to.display()),
Err(e) => log::warn!("preferences: couldn't copy them into the portable folder {}: {e}", to.display()),
}
}
impl Desktop {
+101
View File
@@ -0,0 +1,101 @@
//! Where the settings live (#1320): a portable copy keeps them next to the executable, taking the
//! ones it had before; without a marker they stay in the platform folder they always used. Every
//! folder here is a temporary one: the probe sees no real environment, executable or profile.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use craft_settings::Probe;
use figcraft_engine::Prefs;
use crate::services::{Desktop, settings_dir, write_prefs};
/// A fresh temporary folder with an `app` folder (the executable's) and an `appdata` folder (the
/// platform's config folder).
fn root(name: &str) -> PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-settings-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
std::fs::create_dir_all(r.join("app")).unwrap();
std::fs::create_dir_all(r.join("appdata")).unwrap();
r
}
/// What the rules see: the executable in `root/app`, the platform folder `platform`, and
/// `FIGCRAFT_CONFIG_DIR` set to `config_dir` when given.
fn probe(root: &Path, platform: Option<PathBuf>, config_dir: Option<&'static str>) -> Probe {
Probe {
env: Box::new(move |name| config_dir.filter(|_| name == "FIGCRAFT_CONFIG_DIR").map(OsString::from)),
exe_dir: Some(root.join("app")),
platform_config_dir: platform,
}
}
/// Preferences with a name to recognise them by.
fn prefs(name: &str) -> Prefs {
Prefs { user_name: name.into(), ..Prefs::default() }
}
/// The user name of the preferences the app starts with from `dir`.
fn loaded_name(dir: PathBuf) -> Option<String> {
Desktop::load_from(Some(dir), Arc::new(Mutex::new(None))).prefs.map(|p| p.user_name)
}
#[test]
fn without_a_marker_the_settings_stay_where_they_always_were() {
let root = root("installed");
let old = root.join("appdata").join("FigCraft");
write_prefs(&old, &prefs("Ada"));
let dir = settings_dir(&probe(&root, Some(root.join("appdata")), None));
assert_eq!(dir, Some(old.clone()), "the folder every earlier version used");
assert_eq!(loaded_name(old), Some("Ada".into()), "an existing user's preferences are read");
assert!(!root.join("app").join("config").exists(), "nothing is written next to the executable");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_portable_marker_keeps_the_settings_next_to_the_executable_and_takes_the_old_ones() {
let root = root("portable");
let old = root.join("appdata").join("FigCraft");
write_prefs(&old, &prefs("Ada"));
std::fs::write(root.join("app").join("portable"), "").unwrap();
let portable = root.join("app").join("config");
let dir = settings_dir(&probe(&root, Some(root.join("appdata")), None));
assert_eq!(dir, Some(portable.clone()));
assert_eq!(loaded_name(portable.clone()), Some("Ada".into()), "the copy starts with the settings it had");
assert_eq!(loaded_name(old.clone()), Some("Ada".into()), "the installed app's stay where they were");
// Later starts keep the portable copy's own settings.
write_prefs(&portable, &prefs("Grace"));
assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), None)), Some(portable.clone()));
assert_eq!(loaded_name(portable), Some("Grace".into()), "never overwritten by the old ones again");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_new_portable_copy_starts_without_settings() {
let root = root("fresh");
std::fs::write(root.join("app").join("portable"), "").unwrap();
let portable = root.join("app").join("config");
assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), None)), Some(portable.clone()));
assert_eq!(loaded_name(portable), None, "a first run");
// No platform folder at all: the marker still decides.
assert_eq!(settings_dir(&probe(&root, None, None)), Some(root.join("app").join("config")));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn figcraft_config_dir_names_the_folder_and_empty_means_none() {
let root = root("env");
std::fs::write(root.join("app").join("portable"), "").unwrap();
let named = settings_dir(&probe(&root, Some(root.join("appdata")), Some("elsewhere")));
assert_eq!(named, Some(PathBuf::from("elsewhere")), "ahead of the marker");
assert_eq!(settings_dir(&probe(&root, Some(root.join("appdata")), Some(""))), None, "isolated: nothing read or written");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn without_a_platform_folder_there_are_no_settings() {
let root = root("nowhere");
assert_eq!(settings_dir(&probe(&root, None, None)), None, "not the current directory");
let _ = std::fs::remove_dir_all(&root);
}
+4 -3
View File
@@ -5,10 +5,11 @@
//! and the app quit without a word (#261). Now it is logged and shown in a message box.
/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would
/// keep the caller waiting, and it reads standard error), show it in a message box.
pub fn failed(error: &eframe::Error, quiet: bool) {
/// keep the caller waiting, and it reads standard error), show it in a message box. `tried`: the
/// graphics APIs the app started again with before this failure ([`crate::graphics::Plan::finish`]).
pub fn failed(error: &eframe::Error, quiet: bool, tried: Option<&str>) {
// In the interface language: the text and its catalog entries are the UI crate's.
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some());
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some(), tried);
// Standard error even when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{text}");
+13 -4
View File
@@ -14,14 +14,14 @@ const DRIVER_HINT: &str = if cfg!(windows) {
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
let text = message(ERROR, false);
let text = message(ERROR, false, None);
assert!(text.contains(ERROR), "the error itself: {text}");
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
}
#[test]
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
let text = message(ERROR, false);
let text = message(ERROR, false, None);
assert!(text.contains(tr(DRIVER_HINT)), "{text}");
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!text.contains(tr(BACKEND_HINT)), "{text}");
@@ -30,13 +30,22 @@ fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
#[cfg(windows)]
#[test]
fn on_windows_it_shows_how_to_start_with_opengl() {
let text = message(ERROR, false);
let text = message(ERROR, false, None);
assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}");
}
#[test]
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
let text = message(ERROR, true);
let text = message(ERROR, true, None);
assert!(text.contains(tr(BACKEND_HINT)), "{text}");
assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}");
}
#[test]
fn after_every_attempt_failed_it_names_what_was_tried_and_no_other_api() {
let text = message(ERROR, false, Some("Direct3D 12, Vulkan, OpenGL"));
assert!(text.contains(ERROR), "the last error: {text}");
assert!(text.contains("Direct3D 12, Vulkan, OpenGL"), "what was tried: {text}");
assert!(!text.contains(tr(DRIVER_HINT)), "every API was tried already: {text}");
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
}
+3
View File
@@ -95,3 +95,6 @@ sync_github_name = true
[people.StrajnarFilip]
display_name = "Filip Strajnar"
[people.Carbocode]
sync_github_name = true
+136 -21
View File
@@ -10,14 +10,22 @@
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
//! connection of its own, then joins that file as with a link; a server that doesn't know the
//! code (or can't be reached) ends the join with the reason.
//!
//! Publishing ([`Driver::publish`]) and the requests sent in the background
//! ([`Driver::asking`]) first ask a server things on managing connections of their own (see
//! `exchange.rs`), each waiting for its answers as long as a large upload may take; a publish then
//! joins the new file.
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Presence, Resolve, 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::exchange::{self, Exchange, Then};
use crate::inbox::Inbox;
use crate::invite::Endpoint;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::requests::Publish;
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
@@ -63,13 +71,14 @@ pub(crate) struct Driver<S: Socket> {
pending: Option<Presence>,
throttle: Throttle<Presence>,
reconnect_at: Option<Instant>,
/// What this connection asks a server (a managing one), and what follows its answers.
asking: Option<(Box<Exchange>, Then)>,
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 {
fn new(sock: S, cfg: GuestConfig, inbox: Inbox) -> Self {
Self {
sock,
cfg,
inbox,
@@ -80,47 +89,132 @@ impl<S: Socket> Driver<S> {
pending: None,
throttle: Throttle::new(PRESENCE_INTERVAL),
reconnect_at: None,
asking: None,
done: false,
};
}
}
/// Start joining (progress arrives in `inbox`).
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
let mut d = Self::new(sock, cfg, inbox);
d.inbox.push(Event::Status(Status::Connecting));
d.connect(now);
d
}
/// Start with what `next` asks a server (a background request): the events it ends in go to
/// `inbox`, then the join it ends in, if any, as `cfg` says.
pub(crate) fn asking(sock: S, cfg: GuestConfig, next: exchange::Next, inbox: Inbox, now: Instant) -> Self {
let mut d = Self::new(sock, cfg, inbox);
d.follow(next, now);
d
}
/// Start publishing `job`, then join the new file as `cfg` says (its invite is the new file's).
pub(crate) fn publish(sock: S, cfg: GuestConfig, job: Publish, inbox: Inbox, now: Instant) -> Self {
inbox.push(Event::Status(Status::Connecting));
Self::asking(sock, cfg, exchange::publish(job), inbox, now)
}
/// 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);
/// Ask the next server, or tell the UI and join (or stop).
fn follow(&mut self, next: exchange::Next, now: Instant) {
match next {
exchange::Next::Ask(ex, then) => {
self.asking = Some((ex, then));
self.connect(now);
}
exchange::Next::Done(events, join) => {
for e in events {
self.inbox.push(e);
}
match join {
Some(invite) => {
self.cfg.invite = invite;
self.connect(now);
}
None => self.done = true,
}
}
}
}
/// The connection is open: say hello (or ask about the join code first).
/// The server this connection is to.
fn server(&self) -> &Endpoint {
self.asking.as_ref().map_or(&self.cfg.invite.server, |(ex, _)| &ex.server)
}
fn connect(&mut self, now: Instant) {
self.reconnect_at = None;
self.conn = Some(Conn::new(now));
if let Err(e) = self.sock.open(&self.server().url()) {
self.dropped(e, now);
}
}
/// The connection broke for this reason: a managing one fails, a session's tries again.
fn dropped(&mut self, why: String, now: Instant) {
if self.asking.is_some() { self.answered(Err(why), now) } else { self.end(Outcome::Retry(why), now) }
}
/// The managing connection is over (with why it failed): close it, then what follows.
fn answered(&mut self, r: Result<(), String>, now: Instant) {
let Some((ex, then)) = self.asking.take() else { return };
if self.conn.take().is_some() {
self.sock.close();
}
let answers = match r {
Ok(()) => Ok(ex.answers()),
Err(e) => Err(ex.failed(e)),
};
self.follow(then(answers), now);
}
/// The connection is open: say hello (or ask about the join code first, or open what is asked).
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 = match &self.cfg.code {
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
None => hello(&self.cfg, &self.resume),
let hello = match (&self.asking, &self.cfg.code) {
(Some((ex, _)), _) => ex.opening().clone(),
(None, Some(code)) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
(None, None) => hello(&self.cfg, &self.resume),
};
if !self.write(&hello) {
self.end(Outcome::Retry("the connection dropped".into()), now);
self.dropped("the connection dropped".into(), now);
}
}
/// A binary message from the host.
/// A binary message on a managing connection: the next request, or what follows the answers.
fn answer(&mut self, bytes: &[u8], now: Instant) {
let Some((ex, _)) = self.asking.as_mut() else { return };
let next = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
Ok(m) => ex.take(m),
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
};
let done = ex.done();
match next {
Err(e) => self.answered(Err(e), now),
Ok(Some(req)) if !self.write(&req) => self.answered(Err("the connection to the server dropped".into()), now),
Ok(_) if done => self.answered(Ok(()), now),
Ok(_) => {}
}
}
/// A binary message from the host (or the server asked).
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
let Some(c) = self.conn.as_mut() else { return };
c.last_heard = now;
if self.asking.is_some() {
return self.answer(bytes, now);
}
if bytes.len() > self.cfg.limits.max_server_message {
return self.end(too_large(&self.cfg.limits), now);
}
c.last_heard = now;
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
// The join code's answer: join the file it opens, on a new connection.
Ok(ServerMsg::Resolved { file, token }) if self.cfg.code.is_some() => {
@@ -155,12 +249,23 @@ impl<S: Socket> Driver<S> {
if self.conn.is_none() {
return;
}
if self.asking.is_some() {
return self.answered(Err("the server sent a text message".into()), now);
}
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 };
if self.asking.is_some() {
let why = match (opened, reason) {
(false, _) => format!("no answer from {}", self.server().display()),
(true, "") => "the server closed the connection".to_string(),
(true, r) => format!("the server closed the connection: {r}"),
};
return self.answered(Err(why), now);
}
let outcome = match closed(code, reason) {
Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())),
Outcome::Retry(_) if code == 1006 => Outcome::Retry("the connection dropped".into()),
@@ -205,11 +310,12 @@ impl<S: Socket> Driver<S> {
return None;
}
let l = &self.cfg.limits;
let conn = self.conn.as_ref().map(|c| {
if c.opened {
let conn = self.conn.as_ref().map(|c| match (c.opened, self.asking.is_some()) {
(false, _) => [Some(c.started + self.cfg.connect_timeout), None, None],
// A managing connection waits for each answer.
(true, true) => [Some(c.last_heard + l.request_reply_timeout), None, None],
(true, false) => {
[Some(c.last_ping + 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]))
@@ -229,7 +335,13 @@ impl<S: Socket> Driver<S> {
let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
if !opened {
if now >= started + self.cfg.connect_timeout {
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
self.dropped(format!("no answer from {}", self.server().display()), now);
}
return;
}
if self.asking.is_some() {
if now >= last_heard + l.request_reply_timeout {
self.answered(Err("no answer from the server".into()), now);
}
return;
}
@@ -258,6 +370,9 @@ impl<S: Socket> Driver<S> {
/// The connection ended: close it, then stop or plan the reconnect.
fn end(&mut self, outcome: Outcome, now: Instant) {
// Left while asking a server: what it was asked stays done (a file uploaded stays there),
// nothing follows.
self.asking = None;
// Still asking about the join code: whatever went wrong is final.
let outcome = if self.cfg.code.is_some() { unresolved(&self.cfg, outcome) } else { outcome };
if self.conn.take().is_some() {
+219
View File
@@ -0,0 +1,219 @@
//! Requests to a server outside a session for the browser client, free of the browser so they run
//! (and are tested) on any target: an [`Exchange`] is one managing connection (its opening, then
//! requests answered in order, as `manage.rs` does on a blocking socket), and a [`Next`] says what
//! follows its answers: another exchange, or the events for the UI and the file to join. The
//! [`Driver`](crate::driver::Driver) runs them over its socket.
//!
//! Publishing ([`publish`]) uploads the document, makes the links and codes others join with when
//! it shares, then joins the new file with its edit link; [`background`] sends the hosting and
//! sharing requests the engine sends in the background (asking a server about its hosting,
//! starting and stopping a share).
use std::collections::VecDeque;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Manage, OpenHosting, PROTOCOL, ServerMsg};
use figcraft_engine::live::{Event, NetReply, NetRequest, Shared, Status};
use crate::invite::{Endpoint, Invite};
use crate::requests::{self, Publish, REFUSED};
/// One managing connection to a server: what opens it, then requests answered in order.
pub(crate) struct Exchange {
pub(crate) server: Endpoint,
open: ClientMsg,
requests: VecDeque<Admin>,
answers: Answers,
/// The server let the connection in (its first answer came).
ready: bool,
/// A request is out.
asked: bool,
}
/// What an exchange got: the server's hosting (opened without the key) and the answers to the
/// requests, in order.
#[derive(Default)]
pub(crate) struct Answers {
pub(crate) hosting: Option<HostInfo>,
pub(crate) replies: Vec<AdminReply>,
}
/// What follows an exchange's answers (or why it failed).
pub(crate) type Then = Box<dyn FnOnce(Result<Answers, String>) -> Next>;
/// What comes next.
pub(crate) enum Next {
/// Another exchange, on a connection of its own.
Ask(Box<Exchange>, Then),
/// Done: tell the UI these, then join the file this invite names (`None`: nothing to join).
Done(Vec<Event>, Option<Invite>),
}
impl Exchange {
/// Ask `server` `requests`: with its `key`, else (open hosting) as anyone, or as the owner of
/// `file` with its `owner` token.
pub(crate) fn new(server: Endpoint, key: Option<&str>, file: &str, owner: &str, requests: Vec<Admin>) -> Self {
let open = match key {
Some(key) => {
ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() })
}
None => ClientMsg::OpenHosting(OpenHosting {
protocol: PROTOCOL,
client_version: message::client_version(),
file: file.to_string(),
owner: owner.to_string(),
}),
};
Self { server, open, requests: requests.into(), answers: Answers::default(), ready: false, asked: false }
}
/// The connection's first message.
pub(crate) fn opening(&self) -> &ClientMsg {
&self.open
}
/// Every request is answered.
pub(crate) fn done(&self) -> bool {
self.ready && !self.asked && self.requests.is_empty()
}
pub(crate) fn answers(self) -> Answers {
self.answers
}
/// Why the exchange failed with `e`, as the app says it: a server older than open hosting
/// can't read the opening.
pub(crate) fn failed(&self, e: String) -> String {
if self.ready || !matches!(self.open, ClientMsg::OpenHosting(_)) { e } else { requests::hosting_error(&self.server, e) }
}
/// Take in one message from the server: the next request to send, if one is due; an error
/// ends the exchange.
pub(crate) fn take(&mut self, m: ServerMsg) -> Result<Option<ClientMsg>, String> {
match m {
ServerMsg::Refused { reason } => return Err(self.failed(format!("{REFUSED}{reason}"))),
ServerMsg::Pong => return Ok(None),
ServerMsg::Admin(r) if self.asked => self.answers.replies.push(*r),
ServerMsg::Admin(r) if !self.ready && matches!(self.open, ClientMsg::Manage(_)) => match *r {
AdminReply::Ready => self.ready = true,
other => return Err(format!("the server answered {other:?} instead of letting this connection manage files")),
},
ServerMsg::Hosting(info) if !self.ready && matches!(self.open, ClientMsg::OpenHosting(_)) => {
self.ready = true;
self.answers.hosting = Some(*info);
}
_ => return Err("the server sent something other than an answer".into()),
}
self.asked = false;
Ok(self.requests.pop_front().map(|r| {
self.asked = true;
ClientMsg::Admin(r)
}))
}
}
/// The UI's failure: why the file couldn't be published (it stays as it was).
fn not_published(e: &str) -> Next {
Next::Done(vec![Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })], None)
}
/// Publish `job` (see the module docs): [`Event::Hosting`] (published without the key),
/// [`Event::Published`] and, to share, [`Event::Shared`], then the join.
pub(crate) fn publish(job: Publish) -> Next {
let Publish { server, key, name, doc, share } = job;
let upload = match requests::upload(&name, &doc) {
Ok(r) => r,
Err(e) => return not_published(&e),
};
let ex = Exchange::new(server.clone(), key.as_deref(), "", "", vec![upload]);
Next::Ask(
Box::new(ex),
Box::new(move |answers| {
let (hosting, uploaded) = match answers {
Ok(a) => (a.hosting, requests::uploaded(&server, a.replies.into_iter().next())),
Err(e) => (None, Err(e)),
};
let (edit, published) = match uploaded {
Ok(done) => done,
Err(e) => return not_published(&e),
};
let mut events: Vec<Event> = hosting.map(Event::Hosting).into_iter().collect();
events.push(Event::Published(published.clone()));
if !share {
return Next::Done(events, Some(edit));
}
// The file's first links stay its owner's (this app joins with one): others get their
// own, which stopping takes away again.
let owner = published.owner.unwrap_or_default();
let then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next> = Box::new(move |made| {
events.push(Event::Shared(made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))));
Next::Done(events, Some(edit))
});
share_links(Sharing { server, key, file: published.file, owner, revoke: Vec::new(), create: true }, then)
}),
)
}
/// What [`share_links`] changes: who else gets into `file` (as `manage::share` does).
struct Sharing {
server: Endpoint,
key: Option<String>,
file: String,
owner: String,
revoke: Vec<String>,
create: bool,
}
/// Revoke the links `s.revoke` names, then (to create) make new ones and ask for their codes on a
/// connection of their own; `then` gets the links.
fn share_links(s: Sharing, then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>) -> Next {
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::link_requests(&s.file, &s.revoke, s.create));
Next::Ask(
Box::new(ex),
Box::new(move |answers| {
let links = match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
Ok(Some(links)) => links,
other => return then(other.map(|_| None)),
};
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
Next::Ask(
Box::new(ex),
Box::new(move |codes| {
// A server from before join codes: the links are shared without them.
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
}),
)
}),
)
}
/// Background request `req` (answered as [`Event::Net`] with `job`), for the requests the
/// browser sends: about a server's hosting, and changing who else gets into a file. `None` for
/// the others.
pub(crate) fn background(job: u64, req: NetRequest) -> Option<Next> {
let answer = move |reply: NetReply| Next::Done(vec![Event::Net { job, reply: Box::new(reply) }], None);
Some(match req {
NetRequest::Hosting { server, file, owner, req } => match Endpoint::parse(&server) {
Err(e) => answer(NetReply::Hosting(Err(e))),
Ok((server, _)) => Next::Ask(
Box::new(Exchange::new(server.clone(), None, &file, &owner, req.into_iter().collect())),
Box::new(move |answers| {
let r = answers.and_then(|a| {
let info = a.hosting.ok_or("the server didn't say about its hosting")?;
Ok((info, requests::hosted(&server, a.replies.into_iter().next())))
});
answer(NetReply::Hosting(r))
}),
),
},
NetRequest::Share { server, file, owner, revoke, create } => match Endpoint::parse(&server) {
Err(e) => answer(NetReply::Share(Err(e))),
// As the server's operator when the address carries its key, else as the file's owner.
Ok((server, query)) => {
let key = requests::server_key(None, query);
share_links(Sharing { server, key, file, owner, revoke, create }, Box::new(move |r| answer(NetReply::Share(r))))
}
},
NetRequest::Plugins { .. } | NetRequest::Library { .. } | NetRequest::Upload(_) => return None,
})
}
+335
View File
@@ -0,0 +1,335 @@
//! The browser's requests to a server outside a session ([`crate::exchange`]), run by its
//! [`Driver`] over a scripted socket with a made-up clock: publishing (with and without the
//! server's key, sharing or not, then joining the new file), what a failure leaves, and the
//! background requests the browser can send.
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, FileInfo, HostInfo, LinkInfo, NewLink, Presence, ServerMsg};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, NetReply, NetRequest, Shared, Status};
use crate::driver::{Driver, Socket};
use crate::exchange::{self, Next};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
use crate::join::GuestConfig;
use crate::requests::Publish;
const FILE: &str = "0123456789abcdef";
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";
const SERVER: &str = "ws://127.0.0.1:47910/";
/// A socket that records what the driver does.
#[derive(Default)]
struct Script {
opened: Vec<String>,
sent: Vec<ClientMsg>,
closes: usize,
}
impl Socket for Script {
fn open(&mut self, url: &str) -> Result<(), String> {
self.opened.push(url.to_string());
Ok(())
}
fn send(&mut self, bytes: &[u8]) -> bool {
self.sent.push(message::decode(bytes, usize::MAX).unwrap());
true
}
fn close(&mut self) {
self.closes += 1;
}
}
fn server() -> Endpoint {
Endpoint::parse(SERVER).unwrap().0
}
fn job(key: Option<&str>, share: bool) -> Publish {
let mut doc = Document::new("Poster");
doc.repair();
Publish { server: server(), key: key.map(String::from), name: "Poster".into(), doc: Arc::new(doc), share }
}
fn config() -> GuestConfig {
GuestConfig::new(Invite { server: server(), file: String::new(), token: String::new() }, "Ada".into())
}
/// A background request's driver.
fn run(next: Next, t0: Instant) -> (Driver<Script>, Inbox) {
let inbox = Inbox::new(Arc::new(|| {}));
(Driver::asking(Script::default(), config(), next, inbox.clone(), t0), inbox)
}
/// A publish's driver.
fn publish(job: Publish, t0: Instant) -> (Driver<Script>, Inbox) {
let inbox = Inbox::new(Arc::new(|| {}));
(Driver::publish(Script::default(), config(), job, inbox.clone(), t0), inbox)
}
fn new_link(id: &str, access: Access, token: &str) -> NewLink {
NewLink { link: LinkInfo { id: id.into(), file: FILE.into(), access, label: String::new(), created: 0 }, token: token.into() }
}
fn file() -> FileInfo {
FileInfo { id: FILE.into(), name: "Poster".into(), created: 0, updated: 0, seq: 0, bytes: 0, people: 0 }
}
fn hosting() -> ServerMsg {
ServerMsg::Hosting(Box::new(HostInfo { open: true, operator: "ops@example.com".into(), ..HostInfo::default() }))
}
fn admin(r: AdminReply) -> ServerMsg {
ServerMsg::Admin(Box::new(r))
}
fn hosted() -> ServerMsg {
admin(AdminReply::Hosted {
file: file(),
edit: new_link("e0", Access::Edit, TOKEN),
view: new_link("v0", Access::View, "ViewViewViewViewViewVi"),
owner: "owner-token".into(),
expires: Some(9),
})
}
/// The server says `m` to the driver.
fn say(d: &mut Driver<Script>, m: &ServerMsg, at: Instant) {
d.message(&message::encode(m).unwrap(), at);
}
fn last(d: &Driver<Script>) -> &ClientMsg {
d.sock.sent.last().unwrap()
}
fn failure(events: &[Event]) -> Option<&str> {
events.iter().find_map(|e| if let Event::Status(Status::Failed { reason }) = e { Some(reason.as_str()) } else { None })
}
/// The welcome a server sends Ada in the published file, encoded.
fn welcome() -> ServerMsg {
let mut doc = Document::new("Poster");
doc.repair();
let mut server = figcraft_collab::Server::new(doc, TOKEN, figcraft_collab::Limits::default());
let ClientMsg::Hello(h) = figcraft_collab::Client::hello(FILE, TOKEN, "Ada", "") else { panic!("no hello") };
let (_, outs) = server.join(h).unwrap();
outs.into_iter().find_map(|o| if let figcraft_collab::Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap()
}
/// The server makes the links and codes for others, each step checked.
fn make_links(d: &mut Driver<Script>, t0: Instant) {
// As the file's owner, on a connection of its own.
assert_eq!((d.sock.opened.len(), d.sock.closes), (2, 1));
d.opened(t0);
assert!(matches!(last(d), ClientMsg::OpenHosting(o) if o.file == FILE && o.owner == "owner-token"));
say(d, &hosting(), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::Edit, .. })));
say(d, &admin(AdminReply::Link(new_link("e1", Access::Edit, "EditEditEditEditEditEd"))), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::View, .. })));
say(d, &admin(AdminReply::Link(new_link("v1", Access::View, "SeeSeeSeeSeeSeeSeeSeeS"))), t0);
// Their codes, on another.
d.opened(t0);
say(d, &ServerMsg::Pong, t0);
say(d, &hosting(), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::Code { token, .. }) if token == "EditEditEditEditEditEd"));
say(d, &admin(AdminReply::Code { code: "ABCDEFGHJ".into() }), t0);
say(d, &admin(AdminReply::Code { code: "KLMNPQRST".into() }), t0);
}
#[test]
fn publishing_without_the_key_uploads_shares_then_joins() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(None, true), t0);
assert_eq!(d.sock.opened, vec![SERVER], "no key or token in the URL");
assert_eq!(inbox.drain().len(), 1, "connecting");
d.opened(t0);
assert!(matches!(last(&d), ClientMsg::OpenHosting(o) if o.file.is_empty() && o.owner.is_empty()));
// Presence waits for the session.
d.presence(Presence { cursor: Some((NodeId(1), 1.0, 2.0)), ..Presence::default() }, t0);
say(&mut d, &hosting(), t0);
assert!(matches!(last(&d), ClientMsg::Admin(Admin::Publish { name, .. }) if name == "Poster"));
say(&mut d, &hosted(), t0);
make_links(&mut d, t0);
let events = inbox.drain();
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view) }))] = events.as_slice() else {
panic!("{events:#?}")
};
assert_eq!(h.operator, "ops@example.com");
assert_eq!((p.file.as_str(), p.owner.as_deref(), p.expires), (FILE, Some("owner-token"), Some(9)));
assert_eq!((edit.id.as_str(), edit.code.as_deref(), view.code.as_deref()), ("e1", Some("ABCDEFGHJ"), Some("KLMNPQRST")));
assert!(edit.link.contains("token=EditEditEditEditEditEd"));
// Then the file, with the owner's edit link.
assert_eq!(d.sock.opened.len(), 4);
d.opened(t0);
let ClientMsg::Hello(hello) = last(&d) else { panic!("{:?}", last(&d)) };
assert_eq!((hello.file.as_str(), hello.token.as_str()), (FILE, TOKEN));
say(&mut d, &welcome(), t0);
assert!(matches!(last(&d), ClientMsg::Presence(_)), "the presence that waited goes out with the welcome");
assert!(matches!(inbox.drain().first(), Some(Event::Welcome { .. })));
assert!(!d.done());
}
#[test]
fn publishing_with_the_key_joins_without_sharing() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(Some("k3y"), false), t0);
d.opened(t0);
assert!(matches!(last(&d), ClientMsg::Manage(m) if m.key == "k3y"));
say(&mut d, &admin(AdminReply::Ready), t0);
let published = AdminReply::Published { file: file(), edit: new_link("e0", Access::Edit, TOKEN), view: new_link("v0", Access::View, TOKEN) };
say(&mut d, &admin(published), t0);
let events = inbox.drain();
assert!(matches!(events.as_slice(), [Event::Status(Status::Connecting), Event::Published(p)] if p.owner.is_none()), "{events:#?}");
d.opened(t0);
assert!(matches!(last(&d), ClientMsg::Hello(h) if h.token == TOKEN));
}
#[test]
fn a_failed_publish_says_why_and_joins_nothing() {
let t0 = Instant::now();
let refusals = [
(ServerMsg::Refused { reason: "that message couldn't be read".into() }, "takes files only with its key (it is older than this app)"),
(ServerMsg::Refused { reason: "this server takes files only with its key".into() }, "the server refused: this server takes files"),
(admin(AdminReply::Ready), "something other than an answer"),
];
for (m, why) in refusals {
let (mut d, inbox) = publish(job(None, true), t0);
d.opened(t0);
say(&mut d, &m, t0);
let events = inbox.drain();
assert!(failure(&events).is_some_and(|r| r.starts_with("Couldn't publish to the server: ") && r.contains(why)), "{events:#?}");
assert!(d.done() && d.sock.opened.len() == 1, "nothing more is tried");
}
// The upload's own answer fails, or the key is wrong.
let (mut d, inbox) = publish(job(Some("bad"), false), t0);
d.opened(t0);
say(&mut d, &admin(AdminReply::Failed { reason: "the file is too large".into() }), t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("answered Failed")));
let (mut d, inbox) = publish(job(Some("k"), false), t0);
d.opened(t0);
say(&mut d, &admin(AdminReply::Ready), t0);
say(&mut d, &admin(AdminReply::Failed { reason: "the file is too large".into() }), t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the file is too large")));
// The server goes away, or never answers.
let (mut d, inbox) = publish(job(None, false), t0);
d.opened(t0);
d.closed(1000, "busy", t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the server closed the connection: busy")));
let (mut d, inbox) = publish(job(None, false), t0);
d.closed(1006, "", t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from 127.0.0.1:47910")));
let (mut d, inbox) = publish(job(None, false), t0);
d.opened(t0);
d.text(t0);
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the server sent a text message")));
}
#[test]
fn a_publish_waits_for_the_server_then_gives_up() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(None, false), t0);
// Until the connection opens, then as long as a large upload may take.
assert_eq!(d.deadline(), Some(t0 + Duration::from_secs(5)));
d.opened(t0);
let wait = crate::limits::NetLimits::default().request_reply_timeout;
assert_eq!(d.deadline(), Some(t0 + wait));
d.tick(t0 + wait - Duration::from_secs(1));
assert!(!d.done());
d.tick(t0 + wait);
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from the server")));
assert!(d.done() && d.deadline().is_none());
// One that never opens.
let (mut d, inbox) = publish(job(None, false), t0);
d.tick(t0 + Duration::from_secs(5));
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from 127.0.0.1:47910")));
}
#[test]
fn leaving_while_publishing_stops_there() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(None, false), t0);
d.opened(t0);
inbox.drain();
d.leave(t0);
assert!(d.done() && d.sock.closes == 1);
say(&mut d, &hosting(), t0);
assert!(inbox.drain().is_empty(), "nothing more happens");
}
#[test]
fn sharing_that_fails_still_joins_the_file() {
let t0 = Instant::now();
let (mut d, inbox) = publish(job(None, true), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
say(&mut d, &hosted(), t0);
d.opened(t0);
say(&mut d, &ServerMsg::Refused { reason: "wrong owner token".into() }, t0);
let events = inbox.drain();
assert!(matches!(events.last(), Some(Event::Shared(Err(e))) if e.contains("wrong owner token")), "{events:#?}");
assert_eq!(d.sock.opened.len(), 3, "the file is joined anyway");
// A server from before join codes shares the links without them.
let (mut d, inbox) = publish(job(None, true), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
say(&mut d, &hosted(), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
say(&mut d, &admin(AdminReply::Link(new_link("e1", Access::Edit, TOKEN))), t0);
say(&mut d, &admin(AdminReply::Link(new_link("v1", Access::View, TOKEN))), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
d.closed(1000, "that message couldn't be read", t0);
let events = inbox.drain();
assert!(matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none())));
}
/// Run background request `req` to its answer against `answers` (one per message the driver sends).
fn background(req: NetRequest, answers: &[ServerMsg]) -> (NetReply, Vec<ClientMsg>) {
let t0 = Instant::now();
let (mut d, inbox) = run(exchange::background(7, req).unwrap(), t0);
let mut connections = 0;
for m in answers {
// Each exchange on a connection of its own: it opens, then the server answers.
if d.sock.opened.len() > connections {
connections = d.sock.opened.len();
d.opened(t0);
}
say(&mut d, m, t0);
}
assert!(d.done(), "{:?}", d.sock.sent);
let events = inbox.drain();
let [Event::Net { job: 7, reply }] = events.as_slice() else { panic!("{events:#?}") };
((**reply).clone(), d.sock.sent.clone())
}
#[test]
fn background_requests_ask_about_hosting_and_sharing() {
let req = |r| NetRequest::Hosting { server: SERVER.into(), file: FILE.into(), owner: "owner-token".into(), req: r };
let (reply, sent) = background(req(None), &[hosting()]);
assert!(matches!(reply, NetReply::Hosting(Ok((h, None))) if h.open));
assert!(matches!(sent.as_slice(), [ClientMsg::OpenHosting(o)] if o.owner == "owner-token"));
// A link it makes comes back whole.
let create = Admin::CreateLink { file: FILE.into(), access: Access::View, label: String::new() };
let (reply, _) = background(req(Some(create)), &[hosting(), admin(AdminReply::Link(new_link("v2", Access::View, TOKEN)))]);
assert!(
matches!(reply, NetReply::Hosting(Ok((_, Some(AdminReply::Link(l))))) if l.token.starts_with("figcraft://join?") && l.token.contains(TOKEN))
);
// Stopping a share revokes its links, with the key when the address carries it.
let stop =
NetRequest::Share { server: format!("{SERVER}?key=k3y"), file: FILE.into(), owner: String::new(), revoke: vec!["e1".into()], create: false };
let (reply, sent) = background(stop, &[admin(AdminReply::Ready), admin(AdminReply::Done)]);
assert!(matches!(reply, NetReply::Share(Ok(None))));
assert!(matches!(sent.as_slice(), [ClientMsg::Manage(m), ClientMsg::Admin(Admin::Revoke { link, .. })] if m.key == "k3y" && link == "e1"));
// Addresses that aren't one answer at once; what the browser doesn't send stays the engine's.
let bad_hosting = NetRequest::Hosting { server: "nope".into(), file: String::new(), owner: String::new(), req: None };
let bad_share = NetRequest::Share { server: "http://x".into(), file: FILE.into(), owner: String::new(), revoke: Vec::new(), create: true };
for bad in [bad_hosting, bad_share] {
let Some(Next::Done(events, None)) = exchange::background(1, bad) else { panic!("not answered at once") };
assert!(matches!(events.as_slice(), [Event::Net { reply, .. }] if matches!(**reply, NetReply::Hosting(Err(_)) | NetReply::Share(Err(_)))));
}
let plugins = NetRequest::Plugins { server: SERVER.into(), req: figcraft_collab::message::PluginReq::List { query: String::new() } };
assert!(exchange::background(1, plugins).is_none());
}
+5 -26
View File
@@ -16,8 +16,7 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel}
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use figcraft_collab::Replica;
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Presence, ServerMsg};
use figcraft_collab::message::{self, ClientMsg, HostInfo, Presence, ServerMsg};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, Published, Status};
use tungstenite::protocol::frame::coding::CloseCode;
@@ -30,20 +29,10 @@ pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::manage;
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::requests;
pub use crate::requests::Publish;
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// What [`Guest::publish`] uploads, and where.
pub struct Publish {
pub server: Endpoint,
/// The server's key (`None`: publish as anyone may, on a server open to the public).
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
pub share: bool,
}
/// A joined (or joining) session. Dropping it leaves.
pub struct Guest {
cmd: Sender<Cmd>,
@@ -183,8 +172,7 @@ pub(crate) fn upload_file(
limits: &NetLimits,
timeout: Duration,
) -> Result<(Invite, Published, Option<HostInfo>), String> {
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
let publish = vec![Admin::Publish { name: name.to_string(), snapshot }];
let publish = vec![requests::upload(name, doc)?];
let (replies, hosting) = match key {
Some(key) => (manage::request(server, key, publish, limits, timeout)?, None),
None => {
@@ -192,16 +180,7 @@ pub(crate) fn upload_file(
(replies, Some(info))
}
};
let (file, edit, view, owner, expires) = match replies.into_iter().next() {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
Some(AdminReply::Failed { reason }) => return Err(reason),
_ => return Err("the server didn't say where the file is".into()),
};
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
let (edit, view) = (link(edit.token), link(view.token));
let published =
Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link(), owner, expires };
let (edit, published) = requests::uploaded(server, replies.into_iter().next())?;
Ok((edit, published, hosting))
}
+3 -3
View File
@@ -305,7 +305,7 @@ impl<B: Backend> Hub<B> {
let reply = self.removed_reply(&file, client);
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
}
ClientMsg::Admin(req) => self.admin(conn, req),
ClientMsg::Admin(req) => self.admin(conn, ip, req),
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
},
@@ -538,10 +538,10 @@ impl<B: Backend> Hub<B> {
}
}
fn admin(&mut self, conn: u64, req: Admin) {
fn admin(&mut self, conn: u64, ip: IpAddr, req: Admin) {
let rooms = &self.rooms;
let open = |file: &str| rooms.get(file).map(Room::info);
let handled = self.backend.admin(req, &open);
let handled = self.backend.admin(ip, req, &open);
self.handled(conn, handled);
}
+3 -2
View File
@@ -76,8 +76,9 @@ pub trait Backend: Send + 'static {
Err("this session is hosted by an app: it has no files to manage".into())
}
/// A request on a managing connection; `open` tells about a file that is open.
fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
/// A request on a managing connection from `ip` ([`ip_key`]); `open` tells about a file that
/// is open.
fn admin(&mut self, _ip: IpAddr, _req: Admin, _open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
AdminReply::Failed { reason: "nothing to manage here".into() }.into()
}
+10 -3
View File
@@ -8,17 +8,20 @@
//! 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'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.
//! The browser build's [`NetLink`] joins sessions and publishes files to servers through the
//! browser's WebSocket API (`web-sys`); hosting a session needs the desktop app. Its connection
//! logic (`driver.rs`, `exchange.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;
#[cfg(any(target_arch = "wasm32", test))]
mod exchange;
pub mod inbox;
pub mod invite;
mod join;
pub mod limits;
mod requests;
mod sessions;
pub mod throttle;
@@ -57,12 +60,16 @@ mod code_tests;
#[cfg(test)]
mod driver_tests;
#[cfg(test)]
mod exchange_tests;
#[cfg(test)]
mod invite_tests;
#[cfg(test)]
mod links_tests;
#[cfg(test)]
mod loopback_tests;
#[cfg(test)]
mod rename_tests;
#[cfg(test)]
mod session_tests;
#[cfg(test)]
mod sessions_tests;
+6 -12
View File
@@ -18,6 +18,7 @@ use crate::inbox::{Inbox, Waker};
use crate::invite::{Endpoint, Invite};
use crate::manage;
use crate::net::NetLimits;
use crate::requests;
use crate::sessions::Sessions;
use crate::token;
@@ -176,7 +177,7 @@ impl LiveLink for NetLink {
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
let (server, query) = Endpoint::parse(&o.server)?;
// Without a key: as anyone may, when the server is open to the public (it says if not).
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = requests::server_key(o.key, query);
// Until the upload is done the invite names only the server.
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
let inbox = self.sessions.inbox();
@@ -287,8 +288,8 @@ impl LiveLink for NetLink {
/// Keep `o.doc` on a server as a new file (see [`LiveLink::upload`]).
fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
let (server, query) = Endpoint::parse(&o.server)?;
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
let key = requests::server_key(o.key, query)
.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
}
@@ -297,21 +298,14 @@ fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &NetLimits) -> Result<(HostInfo, Option<AdminReply>), String> {
let (endpoint, _) = Endpoint::parse(server)?;
let (info, replies) = manage::hosting(&endpoint, file, owner, req.into_iter().collect(), limits, CONNECT)?;
let reply = replies.into_iter().next().map(|r| match r {
AdminReply::Link(mut l) => {
l.token = Invite { server: endpoint.clone(), file: l.link.file.clone(), token: l.token }.link();
AdminReply::Link(l)
}
other => other,
});
Ok((info, reply))
Ok((info, requests::hosted(&endpoint, replies.into_iter().next())))
}
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
/// the file's owner.
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
let (endpoint, query) = Endpoint::parse(server)?;
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
let key = requests::server_key(None, query);
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
}
+12 -43
View File
@@ -7,14 +7,16 @@
use std::time::Duration;
use figcraft_collab::message::{
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Resolve, ServerMsg,
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
PluginReq, Resolve, ServerMsg,
};
use figcraft_engine::live::{Shared, SharedLink};
use figcraft_engine::live::Shared;
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::{Endpoint, Invite};
use crate::net::{Duplex, NetLimits, config, open};
pub(crate) use crate::requests::REFUSED;
use crate::requests::{code_requests, hosting_error, link_requests, made_links, shared};
type Ws = WebSocket<Duplex>;
@@ -62,16 +64,11 @@ pub fn hosting(
let open = OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), file: file.to_string(), owner: owner.to_string() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::OpenHosting(open))?;
let info = match reply(&mut ws, limits, |m| match m {
let info = reply(&mut ws, limits, |m| match m {
ServerMsg::Hosting(i) => Some(*i),
_ => None,
}) {
Ok(i) => i,
Err(e) if e.contains("couldn't be read") => {
return Err(format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display()));
}
Err(e) => return Err(e),
};
})
.map_err(|e| hosting_error(server, e))?;
let mut replies = Vec::with_capacity(requests.len());
for r in requests {
send(&mut ws, &ClientMsg::Admin(r))?;
@@ -116,37 +113,12 @@ pub fn share(
Some(key) => request(server, key, requests, limits, timeout),
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
};
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
let mut made = Vec::new();
for reply in ask(requests)? {
match reply {
AdminReply::Link(l) => made.push(l),
// A link that is gone already is as revoked as it gets.
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
AdminReply::Failed { reason } => return Err(reason),
_ => {}
}
}
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
};
let Some(links) = made_links(ask(link_requests(file, revoke, create))?, create)? else { return Ok(None) };
// Their codes, on a connection of its own: a server from before join codes can't read the
// request and closes it (the links are shared without codes then).
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
.unwrap_or_default()
.into_iter()
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut shared = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let edit = shared(edit);
Ok(Some(Shared { edit, view: Some(shared(view)) }))
let codes =
ask(code_requests(file, &links)).map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display())).unwrap_or_default();
Ok(Some(shared(server, file, links, codes)))
}
/// Send one library request about the file behind share link `link`; the reply. Fails when the
@@ -225,9 +197,6 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
}
/// How an error starts when the server said no (then its reason), rather than not answering.
pub(crate) const REFUSED: &str = "the server refused: ";
/// The next answer `pick` takes, or why there is none.
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
loop {
+34
View File
@@ -0,0 +1,34 @@
//! Your name changed in Preferences while in a live session: everyone there sees the new one,
//! people who join later too, and a rename made while joining arrives once the host reads it
//! (#1380).
use serde_json::json;
use crate::session_tests::{host_and_join, pump, session};
#[test]
fn a_rename_reaches_everyone_in_the_session() {
let (mut host, mut guest, hana, ada) = host_and_join();
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals);
host.message = None;
guest.execute("pref.userName", &json!({"name": "Bob"})).unwrap();
assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob", "you see it at once");
pump(&mut host, &mut guest, "the guest's new name at the host", |h, _| h.live().peers[&ada].name == "Bob");
assert_eq!(host.message, None, "a rename isn't announced as someone joining");
host.execute("pref.userName", &json!({"name": "Kim"})).unwrap();
pump(&mut host, &mut guest, "the host's new name at the guest", |_, g| g.live().peers[&hana].name == "Kim");
assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob", "the server confirmed the guest's");
// Renamed while joining: the hello had the old name, the new one follows once signals work.
let mut late = session("Lin");
late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap();
late.execute("pref.userName", &json!({"name": "Lou"})).unwrap();
pump(&mut host, &mut late, "the late joiner's name", |h, l| {
h.live().peers.values().any(|p| p.name == "Lou") && l.live().info.as_ref().is_some_and(|i| i.name == "Lou")
});
let names: Vec<&str> = late.live().peers.values().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"Kim") && names.contains(&"Bob"), "newcomers get the new names: {names:?}");
// Blank goes back to the default for comments; the session keeps the name it shows.
guest.execute("pref.userName", &json!({"name": " "})).unwrap();
assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob");
host.execute("collab.stop", &json!({})).unwrap();
}
+123
View File
@@ -0,0 +1,123 @@
//! What the app asks a server outside a session, and what the answers mean, free of the
//! transport: the native one (`manage.rs`, `guest.rs`) and the browser's (`exchange.rs`) send the
//! same requests and read the answers the same way.
use std::sync::Arc;
use figcraft_collab::Replica;
use figcraft_collab::message::{Access, Admin, AdminReply, NewLink};
use figcraft_doc::Document;
use figcraft_engine::live::{Published, Shared, SharedLink};
use crate::invite::{Endpoint, Invite};
/// What publishing uploads, and where.
pub struct Publish {
pub server: Endpoint,
/// The server's key (`None`: publish as anyone may, on a server open to the public).
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
/// Share the file once it is there: links and join codes for others
/// ([`Event::Shared`](figcraft_engine::live::Event::Shared)).
pub share: bool,
}
/// How an error starts when the server said no (then its reason), rather than not answering.
pub(crate) const REFUSED: &str = "the server refused: ";
/// The server's key: the one given, else the address's `?key=…`; a blank one is none.
pub(crate) fn server_key(given: Option<String>, query: Vec<(String, String)>) -> Option<String> {
given.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty())
}
/// Why asking `server` about its hosting failed with `e`: a server older than open hosting can't
/// read the question.
pub(crate) fn hosting_error(server: &Endpoint, e: String) -> String {
if e.contains("couldn't be read") {
format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display())
} else {
e
}
}
/// The request that keeps `doc` on a server as a new file named `name`.
pub(crate) fn upload(name: &str, doc: &Document) -> Result<Admin, String> {
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
Ok(Admin::Publish { name: name.to_string(), snapshot })
}
/// What `server`'s answer to [`upload`] means: the edit link to join the file with, and where it
/// is with its links (and its owner token, published without the key).
pub(crate) fn uploaded(server: &Endpoint, reply: Option<AdminReply>) -> Result<(Invite, Published), String> {
let (file, edit, view, owner, expires) = match reply {
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
Some(AdminReply::Failed { reason }) => return Err(reason),
_ => return Err("the server didn't say where the file is".into()),
};
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
let (edit, view) = (link(edit.token), link(view.token));
let published = Published { server: server.display(), file: file.id, name: file.name, edit: edit.link(), view: view.link(), owner, expires };
Ok((edit, published))
}
/// The requests that revoke `file`'s links with the ids in `revoke`, then, to `create`, make a new
/// edit link and a new view link.
pub(crate) fn link_requests(file: &str, revoke: &[String], create: bool) -> Vec<Admin> {
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
requests
}
/// The edit and view links the answers to [`link_requests`] made (`None`: none were asked for),
/// or why the server didn't do it.
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<(NewLink, NewLink)>, String> {
let mut made = Vec::new();
for reply in replies {
match reply {
AdminReply::Link(l) => made.push(l),
// A link that is gone already is as revoked as it gets.
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
AdminReply::Failed { reason } => return Err(reason),
_ => {}
}
}
match (made.pop(), made.pop()) {
(Some(view), Some(edit)) => Ok(Some((edit, view))),
_ if create => Err("the server didn't make the links".into()),
_ => Ok(None),
}
}
/// The requests for the join codes of the links [`made_links`] gave (asked on a connection of
/// their own: a server from before join codes can't read them and closes it).
pub(crate) fn code_requests(file: &str, (edit, view): &(NewLink, NewLink)) -> Vec<Admin> {
[edit, view].into_iter().map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
}
/// What others join `file` on `server` with: the links, with the codes `codes` (the answers to
/// [`code_requests`]; none from a server older than join codes).
pub(crate) fn shared(server: &Endpoint, file: &str, (edit, view): (NewLink, NewLink), codes: Vec<AdminReply>) -> Shared {
let mut codes = codes.into_iter().map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut link = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let edit = link(edit);
Shared { edit, view: Some(link(view)) }
}
/// A hosting answer as the app shows it: a new link whole, in place of its token.
pub(crate) fn hosted(server: &Endpoint, reply: Option<AdminReply>) -> Option<AdminReply> {
reply.map(|r| match r {
AdminReply::Link(mut l) => {
l.token = Invite { server: server.clone(), file: l.link.file.clone(), token: l.token }.link();
AdminReply::Link(l)
}
other => other,
})
}
+3 -3
View File
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
use crate::NetLink;
use crate::loopback_tests::quick;
fn session(name: &str) -> Session {
pub(crate) fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
@@ -20,7 +20,7 @@ fn session(name: &str) -> Session {
}
/// Pump both sessions (as their UIs would every frame) until `f` holds.
fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) {
pub(crate) fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) {
let start = Instant::now();
loop {
a.live_poll();
@@ -35,7 +35,7 @@ fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Sess
}
}
fn host_and_join() -> (Session, Session, u32, u32) {
pub(crate) fn host_and_join() -> (Session, Session, u32, u32) {
let mut host = session("Hana");
let mut guest = session("Ada");
let r = host.execute("collab.host", &json!({"port": 0})).unwrap();
+81 -31
View File
@@ -1,33 +1,36 @@
//! The browser client (step 4 of `docs/collab-protocol.md`): the web build joins a session a
//! desktop app hosts, or a file on a `figcraft-server` (also over `wss://` behind a TLS reverse
//! proxy), through the browser's WebSocket API (`web-sys`), with the same messages as the native
//! guest. The connection logic is the [`Driver`]'s; this file feeds it the socket's
//! events and wakes it with a timer. Hosting needs the desktop app (a page can't listen on a
//! socket).
//! proxy), and publishes files to a server, 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 a session 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 each session's
//! number there and its [`Inbox`](crate::inbox::Inbox). It joins any number of sessions at once
//! (one per open document). Whatever arrives wakes egui through the [`Waker`]; nothing polls.
//! number there and its [`Inbox`]. It joins any number of sessions at once (one per open
//! document), and sends the hosting and sharing requests the engine sends in the background the
//! same way. Whatever arrives wakes egui through the [`Waker`]; nothing polls.
//!
//! An `https://` page can't open an unencrypted `ws://` connection (browsers block mixed content)
//! except to this machine; [`NetLink::join`] says so up front.
//! except to this machine; [`NetLink::join`] and [`NetLink::publish`] say so up front.
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::rc::Rc;
use figcraft_collab::message::{ClientMsg, Presence};
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionId, SessionInfo};
use figcraft_engine::live::{Event, HostOptions, LiveLink, NO_SESSION, NetRequest, PublishOptions, SessionId, SessionInfo};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::closure::Closure;
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
use web_time::Instant;
use crate::driver::{Driver, Socket};
use crate::inbox::Waker;
use crate::invite::Invite;
use crate::exchange;
use crate::inbox::{Inbox, Waker};
use crate::invite::{Endpoint, Invite};
use crate::join::GuestConfig;
use crate::requests::{self, Publish};
use crate::sessions::Sessions;
/// Close code for leaving normally.
@@ -134,21 +137,52 @@ 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:")
/// Whether this page may reach `server`: an `https://` page can't open an unencrypted `ws://`
/// connection, except to this machine.
fn reachable(server: &Endpoint) -> Result<(), String> {
let secure_page = web_sys::window().and_then(|w| w.location().protocol().ok()).is_some_and(|p| p == "https:");
if secure_page && !server.secure && !server.loopback() {
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// address (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or use the desktop app".into());
}
Ok(())
}
/// The browser's [`LiveLink`]: joins sessions; hosting needs the desktop app.
/// Run `driver` as connection `id` until it is done.
fn run(id: u64, driver: Driver<BrowserSocket>) {
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))));
}
}
/// A new connection's number and its socket.
fn socket() -> (u64, BrowserSocket) {
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
(id, BrowserSocket { id, ws: None })
}
/// The browser's [`LiveLink`]: joins sessions and publishes files to servers; hosting a session
/// needs the desktop app.
pub struct NetLink {
/// Each session's number in the thread-local table.
sessions: Sessions<u64>,
/// Answers to requests sent in the background (outlives sessions).
background: Inbox,
}
impl NetLink {
/// `wake` is called whenever something arrives for the UI (egui's `request_repaint`).
pub fn new(wake: Waker) -> Self {
Self { sessions: Sessions::new(wake) }
Self { background: Inbox::new(wake.clone()), sessions: Sessions::new(wake) }
}
/// Session `session` runs on connection `id` now (the one it had ends).
fn started(&mut self, session: SessionId, id: u64, inbox: Inbox) {
if let Some(old) = self.sessions.insert(session, id, inbox) {
with_driver(old, |d, now| d.leave(now));
}
}
fn with(&self, session: SessionId, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
@@ -164,30 +198,44 @@ impl LiveLink for NetLink {
}
fn host(&mut self, _: SessionId, _: HostOptions) -> Result<SessionInfo, String> {
Err("hosting a live session needs the desktop app; in the browser you can join one".into())
Err("hosting a live session needs the desktop app; in the browser you can join one, or share a file through a server".into())
}
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
let (invite, code) = Invite::parse_join(link)?;
if secure_page() && !invite.server.secure && !invite.loopback() {
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// link (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or join from the desktop app".into());
}
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
reachable(&invite.server)?;
let (id, sock) = socket();
let inbox = self.sessions.inbox();
let cfg = GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) };
let driver = Driver::start(BrowserSocket { id, ws: None }, cfg, 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))));
}
if let Some(old) = self.sessions.insert(session, id, inbox) {
with_driver(old, |d, now| d.leave(now));
}
run(id, Driver::start(sock, cfg, inbox.clone(), Instant::now()));
self.started(session, id, inbox);
Ok(())
}
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
let (server, query) = Endpoint::parse(&o.server)?;
reachable(&server)?;
// Without a key: as anyone may, when the server is open to the public (it says if not).
let key = requests::server_key(o.key, query);
// Until the upload is done the invite names only the server.
let cfg = GuestConfig::new(Invite { server: server.clone(), file: String::new(), token: String::new() }, o.me);
let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share };
let (id, sock) = socket();
let inbox = self.sessions.inbox();
run(id, Driver::publish(sock, cfg, job, inbox.clone(), Instant::now()));
self.started(session, id, inbox);
Ok(())
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
let Some(next) = exchange::background(job, req) else { return false };
let (id, sock) = socket();
// The exchanges name their own servers.
let cfg = GuestConfig::new(Invite::new("", "", ""), String::new());
run(id, Driver::asking(sock, cfg, next, self.background.clone(), Instant::now()));
true
}
fn leave(&mut self, session: SessionId) {
if let Some(id) = self.sessions.remove(session) {
with_driver(id, |d, now| d.leave(now));
@@ -203,7 +251,9 @@ impl LiveLink for NetLink {
}
fn poll(&mut self) -> Vec<(SessionId, Event)> {
self.sessions.poll()
let mut out: Vec<(SessionId, Event)> = self.background.drain().into_iter().map(|e| (NO_SESSION, e)).collect();
out.extend(self.sessions.poll());
out
}
}
+63 -3
View File
@@ -102,7 +102,7 @@ fn pages_add_reorder_and_delete() {
fn kind_changes_and_transforms_round_trip() {
let (a, _, frame, kids) = setup();
let mut b = a.document().clone();
b.get_mut(frame).unwrap().kind = NodeKind::Component { description: "A card".into(), props: BTreeMap::new() };
b.get_mut(frame).unwrap().kind = NodeKind::Component { description: "A card".into(), props: BTreeMap::new(), prop_order: Vec::new() };
b.get_mut(kids[0]).unwrap().kind = NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.25 };
b.get_mut(kids[0]).unwrap().transform = figcraft_geom::Affine::new([0.5, 0.1, -0.1, 2.0, 1.0 / 3.0, -7.25]);
round_trip(&a, &b);
@@ -131,9 +131,10 @@ fn document_level_data_round_trips() {
x: 1.0,
y: 2.0,
anchor: None,
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] },
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![], agent: false },
resolved: false,
replies: vec![],
// An AI agent's reply reaches the others with its name and marker.
replies: vec![Message { agent: true, ..Message::new("Claude (agent)", "Done", 2) }],
read_by: None,
});
b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 });
@@ -250,3 +251,62 @@ fn invalid_changes_are_refused_and_lenient_mode_skips_them() {
let mut r = a.clone();
assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok());
}
/// Where `id` goes among `kids`, as placement worked before it searched: after every other sibling
/// that sorts before it by `(key, id)`, counted one by one.
fn place_counting(kids: &mut Vec<NodeId>, keys: &BTreeMap<NodeId, String>, id: NodeId) {
kids.retain(|c| *c != id);
let key = |c: NodeId| keys.get(&c).map_or("", String::as_str);
let at = kids.iter().filter(|c| (key(**c), **c) < (key(id), id)).count();
kids.insert(at, id);
}
#[test]
fn many_sibling_placements_keep_the_order_of_counting_them() {
// The reference counts, so it's quadratic: placing 20,000 here took 20 s before the search
// and 0.3 s after (debug build).
const N: u64 = 5_000;
let mut doc = Document::new("Order");
let page = doc.first_page().unwrap();
let parents = [page, doc.create(page, NodeKind::Frame, "A", 0.0, 0.0, 10.0, 10.0).unwrap()];
let mut r = Replica::new(doc);
let mut rng = crate::sim::Rng::new(1065);
// Few whole keys and the fractions between them: many ties (broken by id), many fractions.
let whole = crate::frac::n_between(None, None, 12).unwrap();
let mut pool = whole.clone();
pool.extend(whole.windows(2).map(|w| crate::frac::between(Some(&w[0]), Some(&w[1])).unwrap()));
let mut keys: BTreeMap<NodeId, String> = parents.iter().map(|p| (*p, r.key(*p).to_string())).collect();
let mut kids: BTreeMap<NodeId, Vec<NodeId>> = parents.iter().map(|p| (*p, r.document().node(*p).unwrap().children.clone())).collect();
let mut parent_of = BTreeMap::new();
// One batch of creations in a scrambled id order, under either parent.
let mut creates = Vec::new();
for i in 0..N {
let (id, p, key) = (NodeId(1_000_000 + (i * 7919) % N), parents[rng.below(2)], pool[rng.below(pool.len())].clone());
keys.insert(id, key.clone());
parent_of.insert(id, p);
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
creates.push(Change::Create { id, kind: Json::of(&NodeKind::Rectangle).unwrap(), parent: Some(p), position: key, props: vec![] });
}
// Then a batch of moves: to another key (often a tie), or under either parent (its own too).
let mut moves = Vec::new();
for _ in 0..N / 2 {
let id = NodeId(1_000_000 + rng.below(N as usize) as u64);
if rng.chance(0.5) {
let key = pool[rng.below(pool.len())].clone();
keys.insert(id, key.clone());
place_counting(kids.get_mut(&parent_of[&id]).unwrap(), &keys, id);
moves.push(Change::Set { id, prop: Prop::Position, value: Json::of(&key).unwrap() });
} else {
let p = parents[rng.below(2)];
let old = parent_of.insert(id, p).unwrap();
kids.get_mut(&old).unwrap().retain(|c| *c != id);
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
moves.push(Change::Set { id, prop: Prop::Parent, value: Json::of(&Some(p)).unwrap() });
}
}
r.apply(&creates, Mode::Strict).unwrap();
r.apply(&moves, Mode::Strict).unwrap();
for p in parents {
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
}
}
+5 -2
View File
@@ -88,7 +88,8 @@ pub struct Limits {
pub max_log_batches: usize,
/// Most changes in those batches together.
pub max_log_changes: usize,
/// About how many bytes those batches may hold together ([`server::weight`]).
/// About how many bytes those batches may hold together ([`server::weight`]). 64 MiB by
/// default, so a long busy session (on a desktop host too) keeps a bounded log.
pub max_log_bytes: usize,
}
@@ -102,7 +103,7 @@ impl Default for Limits {
max_name: 64,
max_log_batches: 10_000,
max_log_changes: 1_000_000,
max_log_bytes: usize::MAX,
max_log_bytes: 64 << 20,
}
}
}
@@ -148,6 +149,8 @@ mod owned_tests;
#[cfg(test)]
mod reconnect_tests;
#[cfg(test)]
mod rename_tests;
#[cfg(test)]
mod server_tests;
#[cfg(test)]
mod signal_tests;
+21 -4
View File
@@ -134,9 +134,11 @@ pub struct EditingText {
/// ignored: a server drops them, a client skips them.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signal {
/// [`Signal::CHAT`], [`Signal::SPOTLIGHT`] or (server to client) [`Signal::READY`].
/// [`Signal::CHAT`], [`Signal::SPOTLIGHT`], (client to server) [`Signal::NAME`] or (server to
/// client) [`Signal::READY`].
pub kind: String,
/// Chat: the message (empty: closed). Spotlight: `"on"`, or empty when it stopped.
/// Chat: the message (empty: closed). Spotlight: `"on"`, or empty when it stopped. Name: the
/// new name.
pub text: String,
}
@@ -147,6 +149,11 @@ impl Signal {
pub const SPOTLIGHT: &str = "spotlight";
/// From the server, about the client itself, right after its welcome: signals may be sent.
pub const READY: &str = "ready";
/// To the server: the client was renamed. The server keeps the new name and tells everyone,
/// the client too, with its [`ServerMsg::PeerJoined`] (it isn't passed on as a signal).
pub const NAME: &str = "name";
/// Longest name shown to others (characters).
pub const MAX_NAME: usize = 64;
/// Longest chat message (characters).
pub const MAX_CHAT: usize = 100;
/// Longest signal on the wire before it is cleaned (bytes of kind and text together).
@@ -164,14 +171,19 @@ impl Signal {
Signal { kind: Signal::READY.into(), text: String::new() }
}
pub fn name(name: &str) -> Signal {
Signal { kind: Signal::NAME.into(), text: name.into() }
}
/// A spotlight that is on.
pub fn on(&self) -> bool {
self.text == "on"
}
/// Untrusted input made safe to pass on or show: `None` for an unknown kind, a malformed
/// spotlight or anything oversized; a chat's control characters become spaces and it is cut to
/// [`MAX_CHAT`](Self::MAX_CHAT) characters (an all-blank one is empty: closed).
/// spotlight, a blank name or anything oversized; a chat's control characters become spaces and
/// it is cut to [`MAX_CHAT`](Self::MAX_CHAT) characters (an all-blank one is empty: closed); a
/// name is trimmed, loses its control characters and is cut to [`MAX_NAME`](Self::MAX_NAME).
pub fn clean(self) -> Option<Signal> {
if self.kind.len() + self.text.len() > Signal::MAX_BYTES {
return None;
@@ -183,6 +195,11 @@ impl Signal {
}
Signal::SPOTLIGHT if self.text.is_empty() || self.on() => Some(self),
Signal::READY if self.text.is_empty() => Some(self),
Signal::NAME => {
let name: String = self.text.trim().chars().filter(|c| !c.is_control()).take(Signal::MAX_NAME).collect();
let name = name.trim_end();
(!name.is_empty()).then(|| Signal::name(name))
}
_ => None,
}
}
+37
View File
@@ -133,6 +133,43 @@ fn a_long_absence_catches_up_with_a_snapshot() {
assert_eq!(a.view(), s.replica());
}
#[test]
fn the_default_log_is_bounded_in_bytes_and_a_client_behind_it_catches_up_with_a_snapshot() {
// The defaults are what a desktop host runs with.
let limits = Limits::default();
assert_eq!(limits.max_log_bytes, 64 << 20);
let mut s = Server::new(base(), "tok", limits);
let mut a = join(&mut s);
let mut b = join(&mut s);
let page = s.document().first_page().unwrap();
a.disconnect();
s.leave(a.id());
// Nine renames of 8 MiB each: more than the log holds, in a few batches.
for i in 0..9 {
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = format!("{i}{}", "y".repeat(8 << 20))).unwrap();
let out = s.receive(b.id(), m);
deliver(&mut b, &out);
assert!(s.log_bytes() <= limits.max_log_bytes, "{} bytes kept", s.log_bytes());
}
assert!(s.log_bytes() > 0, "recent batches are still kept");
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
let (_, out) = s.join(h).unwrap();
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(_), .. })));
deliver(&mut a, &out);
assert_eq!(a.view(), s.replica());
// Within the log, a short absence still gets the missed batch only.
a.disconnect();
s.leave(a.id());
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = "Later".into()).unwrap();
let out = s.receive(b.id(), m);
deliver(&mut b, &out);
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
let (_, out) = s.join(h).unwrap();
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Delta(d), .. }) if d.len() == 1));
deliver(&mut a, &out);
assert_eq!(a.view(), s.replica());
}
#[test]
fn after_a_snapshot_undo_leaves_what_others_changed_meanwhile() {
let mut s = Server::new(base(), "tok", Limits { max_log_batches: 2, ..Limits::default() });
+44
View File
@@ -0,0 +1,44 @@
//! A client renamed in a session (a `name` signal): the server keeps the new name and tells
//! everyone with the client's `PeerJoined`; newcomers' welcomes list it (#1380).
use figcraft_doc::Document;
use crate::message::{ClientMsg, Hello, Peer, ServerMsg, Signal, client_version};
use crate::{Client, Limits, Outgoing, Server};
fn hello(name: &str) -> Hello {
let ClientMsg::Hello(h) = Client::hello("f", "", name, "#FF8800") else { panic!("not a hello") };
Hello { client_version: client_version(), ..h }
}
fn rename(s: &mut Server, from: u32, name: &str) -> Vec<Outgoing> {
s.receive(from, ClientMsg::Signal(Signal::name(name)))
}
#[test]
fn the_new_name_goes_to_everyone_and_to_newcomers() {
let mut s = Server::new(Document::new("Shared"), "", Limits::default());
let (ada, _) = s.join(hello("Ada")).unwrap();
s.join(hello("Hana")).unwrap();
let out = rename(&mut s, ada, " Bob\u{7} ");
let bob = Peer { client_id: ada, name: "Bob".into(), color: "#FF8800".into() };
assert_eq!(out, vec![Outgoing::All(ServerMsg::PeerJoined(bob))], "cleaned, to everyone, Ada too");
let (_, out) = s.join(hello("Lin")).unwrap();
let Some(Outgoing::To(_, ServerMsg::Welcome { peers, .. })) = out.first() else { panic!("no welcome") };
assert!(peers.iter().any(|p| p.client_id == ada && p.name == "Bob"));
// The same name, a blank one, a stranger's and a name past the server's limit change nothing.
assert!(rename(&mut s, ada, "Bob").is_empty());
assert!(rename(&mut s, ada, " \n ").is_empty());
assert!(!rename(&mut s, 999, "Eve").iter().any(|o| matches!(o, Outgoing::All(ServerMsg::PeerJoined(_)))));
let mut tight = Server::new(Document::new("Shared"), "", Limits { max_name: 7, ..Limits::default() });
let (id, _) = tight.join(hello("Ada")).unwrap();
assert!(rename(&mut tight, id, "Long name").is_empty());
assert_eq!(rename(&mut tight, id, "Bo").len(), 1);
}
#[test]
fn a_name_is_cut_to_the_longest_shown() {
let long = "é".repeat(Signal::MAX_NAME + 10);
assert_eq!(Signal::name(&long).clean().map(|s| s.text.chars().count()), Some(Signal::MAX_NAME));
assert_eq!(Signal::name("\t").clean(), None);
}
+8 -2
View File
@@ -175,9 +175,15 @@ impl Replica {
}
/// Where `id` with `key` goes among `siblings` (sorted by `(key, id)`; `id` itself skipped):
/// how many of them sort before it.
/// how many of them sort before it. A binary search on each side of `id`, which may still
/// sit where its old key put it.
fn slot(&self, siblings: &[NodeId], id: NodeId, key: &str) -> usize {
siblings.iter().filter(|c| **c != id && (self.key(**c), **c) < (key, id)).count()
let before = |c: &NodeId| (self.key(*c), *c) < (key, id);
let (head, tail) = match siblings.iter().position(|c| *c == id) {
Some(i) => (siblings.get(..i).unwrap_or_default(), siblings.get(i + 1..).unwrap_or_default()),
None => (siblings, &[][..]),
};
head.partition_point(before) + tail.partition_point(before)
}
/// Apply `changes` in order. Node changes come first in a batch made by [`diff`](crate::diff);
+30 -13
View File
@@ -418,6 +418,9 @@ impl Server {
// current spotlight.
ClientMsg::Signal(sig) => {
let Some(sig) = sig.clean().filter(|s| s.kind != Signal::READY) else { return Vec::new() };
if sig.kind == Signal::NAME {
return self.rename(from, sig.text);
}
if sig.kind == Signal::SPOTLIGHT {
if sig.on() {
self.spotlight = Some(from);
@@ -447,6 +450,15 @@ impl Server {
}
}
/// Client `from` is called `name` from now on (cleaned): everyone, itself too, hears it as its
/// `PeerJoined`, and newcomers' welcomes list it so.
fn rename(&mut self, from: u32, name: String) -> Vec<Outgoing> {
let max = self.limits.max_name;
let Some(peer) = self.peers.get_mut(&from).filter(|p| p.name != name && name.chars().count() <= max) else { return Vec::new() };
peer.name = name;
vec![Outgoing::All(ServerMsg::PeerJoined(peer.clone()))]
}
/// Keep an accepted batch in the log (dropping the oldest beyond the limits).
fn remember(&mut self, acc: Accepted) {
self.log_changes += acc.changes.len();
@@ -557,6 +569,7 @@ impl Server {
let doc = self.state.document();
let mut props: BTreeMap<NodeId, BTreeSet<Prop>> = BTreeMap::new();
let mut gone: BTreeSet<NodeId> = BTreeSet::new();
let (mut asked, mut sent) = (BTreeSet::new(), BTreeSet::new());
let mut out = Vec::new();
for c in changes {
match c {
@@ -569,9 +582,11 @@ impl Server {
Change::Delete { id } => {
gone.insert(*id);
}
// The same target has the same fix: look each one up once, and send each fix once.
other => {
if let Some(fix) = docprops::current(doc, other)?
&& !out.contains(&fix)
if Target::of(other).is_some_and(|t| asked.insert(t))
&& let Some(fix) = docprops::current(doc, other)?
&& Target::of(&fix).is_some_and(|t| sent.insert(t))
{
out.push(fix);
}
@@ -589,19 +604,21 @@ impl Server {
nodes.extend(t.write(self.state.value(&t)?.as_ref()));
}
}
let mut restored = std::collections::BTreeSet::new();
// Each deleted node's subtree, pre-order, every node once: a restored node's whole subtree
// went out with it, so the walk stops there (deleting a deep chain stays linear).
let mut restored = BTreeSet::new();
for id in gone {
match doc.node(id) {
Some(_) => {
for d in doc.subtree(id) {
if restored.insert(d)
&& let Some(n) = doc.node(d)
{
nodes.push(create_change(n, self.state.key(d))?);
}
}
if !doc.contains(id) {
nodes.push(Change::Delete { id });
continue;
}
let mut stack = vec![id];
while let Some(d) = stack.pop() {
let Some(n) = doc.node(d) else { continue };
if restored.insert(d) {
nodes.push(create_change(n, self.state.key(d))?);
stack.extend(n.children.iter().rev());
}
None => nodes.push(Change::Delete { id }),
}
}
nodes.extend(out);
+38 -1
View File
@@ -2,7 +2,7 @@
use figcraft_doc::{Document, Node, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::change::{Change, Entry, Json, Prop};
use crate::message::{ClientMsg, Hello, PROTOCOL, Presence, ServerMsg};
use crate::replica::Mode;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
@@ -217,6 +217,43 @@ fn fixes_roll_a_rejected_client_back_to_the_server_state() {
assert_eq!(b.view(), s.replica());
}
#[test]
fn a_refused_deep_delete_restores_each_node_once_in_linear_time() {
const DEPTH: usize = 20_000;
let (mut d, _, frame) = doc();
let mut chain = vec![frame];
while chain.len() < DEPTH {
let parent = *chain.last().unwrap();
chain.push(d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 1.0, 1.0).unwrap());
}
let mut s = Server::new(d, "tok", Limits::default());
let a = join(&mut s);
// Every node of the chain deleted, plus a rename of a node nobody has: refused whole.
let missing = NodeId(u64::MAX >> 1);
let mut changes: Vec<Change> = chain.iter().map(|&id| Change::Delete { id }).collect();
changes.push(Change::Set { id: missing, prop: Prop::Name, value: Json("x".into()) });
// As many comment threads put, each twice: they go back to not existing, once each.
let put = |c: u64| Change::Put { entry: Entry::Comment(c), value: Json(serde_json::json!({})) };
changes.extend((1..=DEPTH as u64).chain(1..=DEPTH as u64).map(put));
let started = std::time::Instant::now();
let (_, fixes) = rejected(&mut s, a.id(), changes);
let took = started.elapsed();
assert_eq!(fixes.first(), Some(&Change::Delete { id: missing }));
let restored: Vec<NodeId> = fixes[1..=DEPTH]
.iter()
.map(|c| match c {
Change::Create { id, .. } => *id,
other => panic!("not a creation: {other:?}"),
})
.collect();
assert_eq!(restored, chain, "each deleted node comes back once, parents first");
let removed: Vec<Change> = (1..=DEPTH as u64).map(|c| Change::Remove { entry: Entry::Comment(c) }).collect();
assert_eq!(fixes[DEPTH + 1..], removed[..], "each entry once, in batch order");
// Walking every deleted node's subtree again and comparing each fix with all the earlier
// ones took 27 s in a debug build; once each takes 0.1 s.
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
}
#[test]
fn remote_batches_apply_under_pending_ones() {
let (d, _, frame) = doc();
+5 -1
View File
@@ -180,6 +180,9 @@ impl Color {
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
if !(h.is_finite() && s.is_finite() && l.is_finite()) {
return None;
}
let alpha = match parts.get(3) {
Some(a) => parse_alpha(a)?,
None => 1.0,
@@ -286,7 +289,8 @@ fn css_hue(p: &str) -> Option<f32> {
}
fn parse_alpha(a: &str) -> Option<f32> {
if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }
let v = if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }?;
v.is_finite().then_some(v)
}
/// One colour stop of a gradient.
+7
View File
@@ -141,3 +141,10 @@ fn to_css_round_trips_every_alpha() {
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8(), c.to_rgba8(), "alpha {a}: {}", c.to_css());
}
}
#[test]
fn css_hsl_rejects_non_finite() {
for s in ["hsl(nan,50%,50%)", "hsl(inf,50%,50%)", "hsl(0,inf%,50%)", "hsl(0,50%,infinity%)", "hsla(120,50%,50%,nan)"] {
assert_eq!(Color::parse_css(s), None, "{s}");
}
}
+19 -9
View File
@@ -26,6 +26,9 @@ pub struct Message {
pub edited: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reactions: Vec<Reaction>,
/// Written by an AI agent (a command run for an MCP client), not by a person in the app.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub agent: bool,
}
/// A reply in a comment thread.
@@ -37,7 +40,7 @@ fn is_zero(v: &u64) -> bool {
impl Message {
pub fn new(author: impl Into<String>, text: impl Into<String>, created: u64) -> Self {
Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new() }
Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new(), agent: false }
}
/// `user` left the `emoji` reaction.
@@ -180,10 +183,16 @@ impl Comment {
}
}
/// The byte ranges of `@Name` mentions of `people` in `text` (names match ignoring ASCII case; the
/// longest name wins; a mention starts the text or follows a non-alphanumeric character and ends
/// before one).
/// The byte ranges of `@Name` mentions of `people` in `text` (see [`mentions`]).
pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
mentions(text, people).into_iter().filter(|(_, who)| who.is_some()).map(|(r, _)| r).collect()
}
/// Every `@` mention in `text`: its byte range and the one of `people` it names (names match
/// ignoring ASCII case; the longest name wins), or `None` for a name nobody in `people` has (then
/// the letters and digits after the `@`). A mention starts the text or follows a non-alphanumeric
/// character and ends before one, so `a@b.com` mentions nobody.
pub fn mentions<'p>(text: &str, people: &[&'p str]) -> Vec<(Range<usize>, Option<&'p str>)> {
let mut out = Vec::new();
let mut from = 0;
while let Some(off) = text.get(from..).and_then(|t| t.find('@')) {
@@ -194,15 +203,16 @@ pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
continue;
}
let rest = text.get(at + 1..).unwrap_or("");
let best = people
let who = people
.iter()
.copied()
.filter(|n| !n.is_empty())
.filter(|n| rest.get(..n.len()).is_some_and(|h| h.eq_ignore_ascii_case(n)))
.filter(|n| rest.get(n.len()..).and_then(|t| t.chars().next()).is_none_or(|c| !c.is_alphanumeric()))
.map(|n| n.len())
.max();
if let Some(len) = best {
out.push(at..at + 1 + len);
.max_by_key(|n| n.len());
let len = who.map_or_else(|| rest.find(|c: char| !c.is_alphanumeric()).unwrap_or(rest.len()), str::len);
if len > 0 {
out.push((at..at + 1 + len, who));
from = at + 1 + len;
}
}
+15
View File
@@ -65,6 +65,18 @@ pub enum PropDef {
},
}
/// A component's properties in the order they show: the names in `order` first (as they were
/// made; renaming keeps the place), then any others by name (files from before the order was kept).
pub fn ordered_props<'a>(props: &'a BTreeMap<String, PropDef>, order: &[String]) -> Vec<(&'a String, &'a PropDef)> {
let mut out: Vec<(&String, &PropDef)> = Vec::with_capacity(props.len());
for (k, d) in order.iter().filter_map(|k| props.get_key_value(k)).chain(props.iter()) {
if !out.iter().any(|(o, _)| *o == k) {
out.push((k, d));
}
}
out
}
/// What a node is, with per-type data.
// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
// Page's guides moved to the node (#1476).
@@ -115,6 +127,9 @@ pub enum NodeKind {
description: String,
#[serde(default)]
props: BTreeMap<String, PropDef>,
/// The order `props` show in, as they were made: see [`ordered_props`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
prop_order: Vec<String>,
},
ComponentSet {
#[serde(default)]
+6 -5
View File
@@ -1337,13 +1337,14 @@ impl ExportSize {
(value.is_finite() && value > 0.0 && value <= max).then_some(self)
}
/// "2x", "512w", "512h".
/// "2x", "0.125x", "512w", "512h". Not rounded, so `parse` reads it back as the same size.
pub fn label(self) -> String {
let n = |v: f64| crate::text::trim_num(v);
// `{}` prints the shortest text that parses back to the same `f64`, and whole numbers
// without a fraction ("2", not "2.0").
match self {
ExportSize::Scale { value } => format!("{}x", n(value)),
ExportSize::Width { value } => format!("{}w", n(value)),
ExportSize::Height { value } => format!("{}h", n(value)),
ExportSize::Scale { value } => format!("{value}x"),
ExportSize::Width { value } => format!("{value}w"),
ExportSize::Height { value } => format!("{value}h"),
}
}
+7 -1
View File
@@ -268,6 +268,12 @@ impl Variables {
self.variables.iter().find(|v| v.id == id || v.name == id)
}
/// Look up an already resolved variable id (a stored binding or alias): exact ids only, so a
/// variable named like an id never stands in for it.
pub fn get_by_id(&self, id: &str) -> Option<&Variable> {
self.variables.iter().find(|v| v.id == id)
}
pub fn collection(&self, id: &str) -> Option<&Collection> {
self.collections.iter().find(|c| c.id == id || c.name == id)
}
@@ -277,7 +283,7 @@ impl Variables {
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
let mut cur = id.to_string();
for _ in 0..32 {
let var = self.get(&cur)?;
let var = self.get_by_id(&cur)?;
let coll = self.collection(&var.collection)?;
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
match var.values.get(mode).or_else(|| var.values.values().next())? {
+36
View File
@@ -310,6 +310,42 @@ fn variables_resolve_modes_and_aliases() {
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
}
#[test]
fn variables_resolve_stored_ids_never_by_name() {
let mut v = Variables::default();
v.collections.push(Collection {
id: "c1".into(),
name: "Theme".into(),
modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }],
extends: None,
});
let var = |id: &str, name: &str, value: VariableValue| Variable {
id: id.into(),
name: name.into(),
collection: "c1".into(),
kind: VariableType::Color,
values: std::collections::BTreeMap::from([("m".to_string(), value)]),
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
};
let red = Color::from_hex("#FF0000").unwrap();
let blue = Color::from_hex("#0D99FF").unwrap();
// Named like the next variable's id, and like an id that no longer exists.
v.variables.push(var("VariableID:1", "VariableID:2", VariableValue::Color(red)));
v.variables.push(var("VariableID:2", "surface", VariableValue::Color(blue)));
v.variables.push(var("VariableID:3", "VariableID:9", VariableValue::Color(red)));
v.variables.push(var("VariableID:4", "alias", VariableValue::Alias("VariableID:9".into())));
let modes = std::collections::BTreeMap::new();
assert_eq!(v.resolve("VariableID:2", &modes), Some(VariableValue::Color(blue)));
// A deleted id stays unresolved instead of picking up the variable named like it.
assert_eq!(v.resolve("VariableID:9", &modes), None);
assert_eq!(v.resolve("VariableID:4", &modes), None);
}
#[test]
fn serde_roundtrip_and_repair() {
let mut d = Document::new("x");
+52
View File
@@ -445,6 +445,21 @@ pub struct TextData {
/// data small.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<Box<TextPath>>,
/// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout).
/// Layout follows them only while the box width and everything that moves a line break are
/// as they were saved, so an edit that could change the wrap drops them.
#[serde(skip_serializing_if = "Option::is_none")]
pub saved_breaks: Option<Box<SavedBreaks>>,
}
/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a
/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SavedBreaks {
pub starts: Vec<usize>,
pub width: f64,
pub for_text: u64,
}
/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the
@@ -503,6 +518,43 @@ impl TextData {
Self { characters: characters.into(), ..Default::default() }
}
/// A stable hash of what decides where lines break: everything but paints, links, style links,
/// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it
/// stays the same across builds and can be saved).
pub fn layout_key(&self) -> u64 {
let clear = |s: &mut TextStyle| {
(s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None);
};
let mut t = TextData {
align_v: TextAlignV::default(),
paragraph_spacing: 0.0,
list_spacing: 0.0,
leading_trim: LeadingTrim::default(),
max_lines: 0,
saved_breaks: None,
..self.clone()
};
clear(&mut t.style);
t.runs.iter_mut().for_each(|r| clear(&mut r.style));
t.normalize();
serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
}
/// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box
/// `width` wide.
pub fn save_breaks(&mut self, starts: Vec<usize>, width: f64) {
self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() }));
}
/// The saved line starts (empty: no wrapped line) while they still hold: the same box width and
/// layout content, and starts increasing inside the text.
pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> {
let b = self.saved_breaks.as_deref()?;
let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count());
let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0);
(inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice())
}
/// How far text on a path paints past its path's box (glyphs stand on the path, lines after
/// the first run beside it): about one and a half of the largest size per line; 0 off a path.
pub fn path_reach(&self) -> f64 {
+84
View File
@@ -0,0 +1,84 @@
//! An attached AI agent's presence (#1416): its commands set it, on what they edited or selected;
//! it goes a short while after its last command; the user's commands never make one.
use std::time::Duration;
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::agent::LINGER;
fn id(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, agent: Option<&str>, x: i32) -> NodeId {
id(&s.execute_as(agent, "shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap())
}
#[test]
fn an_agents_edit_shows_it_on_the_layers_it_changed() {
let mut s = Session::new();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
let r = id(&s.execute_as(Some("Claude"), "shape.rectangle", &json!({"parent": f, "x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
let p = s.agent_presence.clone().expect("the agent is at work");
assert_eq!(p.name, "Claude (agent)");
assert_eq!(p.doc, s.ds().unwrap().uid);
assert_eq!(p.layers, vec![r], "the new layer, not the frame whose children changed with it");
assert_eq!(s.agent, None, "the command ran as the agent only");
}
#[test]
fn an_agents_selection_shows_it_there_and_a_look_keeps_it() {
let mut s = Session::new();
let (a, b) = (rect(&mut s, None, 0), rect(&mut s, None, 100));
s.execute_as(Some(""), "select.set", &json!({"ids": [a, b]})).unwrap();
let p = s.agent_presence.clone().unwrap();
assert_eq!((p.name.as_str(), p.layers.clone()), ("AI agent", vec![a, b]));
// A command that changes nothing keeps where it works and when it was last seen moves on.
s.execute_as(Some(""), "view.zoomFit", &json!({})).unwrap();
let again = s.agent_presence.clone().unwrap();
assert_eq!(again.layers, vec![a, b]);
assert!(again.at >= p.at);
// So does a failed one.
assert!(s.execute_as(Some(""), "node.set", &json!({"ids": [999_999], "props": {}})).is_err());
assert_eq!(s.agent_presence.as_ref().unwrap().layers, vec![a, b]);
}
#[test]
fn the_presence_goes_a_short_while_after_the_last_command() {
let mut s = Session::new();
rect(&mut s, Some("Claude"), 0);
let at = s.agent_presence.as_ref().unwrap().at;
assert!(s.agent_presence_at(at).is_some());
assert!(s.agent_presence_at(at + LINGER - Duration::from_millis(1)).is_some());
assert_eq!(s.agent_presence.as_ref().unwrap().left(at + Duration::from_secs(2)), LINGER - Duration::from_secs(2));
assert!(s.agent_presence_at(at + LINGER).is_none(), "gone after the linger");
}
#[test]
fn the_users_commands_never_make_one() {
let mut s = Session::new();
let a = rect(&mut s, None, 0);
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("node.set", &json!({"ids": [a], "props": {"x": 50}})).unwrap();
assert!(s.agent_presence.is_none());
// An agent's name on one file: the user's later edits elsewhere don't move it.
rect(&mut s, Some("Claude"), 200);
let before = s.agent_presence.clone();
s.execute("node.set", &json!({"ids": [a], "props": {"x": 80}})).unwrap();
assert_eq!(s.agent_presence, before);
}
#[test]
fn another_file_starts_afresh() {
let mut s = Session::new();
let a = rect(&mut s, Some("Claude"), 0);
assert_eq!(s.agent_presence.as_ref().unwrap().layers, vec![a]);
s.execute("file.new", &json!({})).unwrap();
s.execute_as(Some("Claude"), "view.zoomFit", &json!({})).unwrap();
let p = s.agent_presence.clone().unwrap();
assert_eq!(p.doc, s.ds().unwrap().uid);
assert!(p.layers.is_empty(), "the old file's layers don't follow it");
}
+14
View File
@@ -39,6 +39,7 @@ use figcraft_doc::{
use figcraft_geom::Affine;
use serde_json::Value;
mod cycles;
mod diff;
mod guard;
mod instances;
@@ -47,6 +48,9 @@ mod rebase;
mod sync;
mod variants;
pub use cycles::{
break_component_cycles, check_component_cycles, component_cycle, contains_itself_error, cycle_text, new_cycle, would_contain_itself,
};
pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups};
pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers};
pub(crate) use instances::copy_group;
@@ -280,6 +284,16 @@ pub fn default_props(comp: &Node) -> BTreeMap<String, Value> {
out
}
/// Component `c`'s property definitions, in the order they show ([`figcraft_doc::ordered_props`]).
pub fn prop_list(doc: &Document, c: NodeId) -> Vec<(String, PropDef)> {
match doc.node(c).map(|n| &n.kind) {
Some(NodeKind::Component { props, prop_order, .. }) => {
figcraft_doc::ordered_props(props, prop_order).into_iter().map(|(k, d)| (k.clone(), d.clone())).collect()
}
_ => Vec::new(),
}
}
/// Whether instances store a value for the property (every kind but slots).
pub fn takes_value(d: &PropDef) -> bool {
!matches!(d, PropDef::Slot { .. })
@@ -0,0 +1,214 @@
//! A component can't contain its own instances, directly or through other components (#1084):
//! every instance would show itself without end. Each variant is a component of its own here: one
//! holding an instance of another variant of its set is fine unless that one leads back to it.
//! Commands that would close such a loop are refused ([`check_component_cycles`]);
//! files that hold one are broken on opening ([`break_component_cycles`]); and a rebuild never
//! expands an instance inside a copy of itself ([`expansion_layers`]), for loops that arrive some
//! other way (two edits merged in a live session).
use super::patch::rebuild_instances;
use super::{BTreeMap, BTreeSet, Document, NodeId, NodeKind, PROP_BINDING, PropDef, inside_instance, root_instance};
/// The components each main component holds instances of.
type Graph = BTreeMap<NodeId, BTreeSet<NodeId>>;
fn is_component(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
}
/// The main components `id` is (in).
fn hosts(doc: &Document, id: NodeId) -> BTreeSet<NodeId> {
std::iter::once(id).chain(doc.ancestors(id)).filter(|a| is_component(doc, *a)).collect()
}
/// The components instance `id` shows: its main, and those its instance swap properties pick
/// (they swap a layer inside it when it's rebuilt).
fn shown(doc: &Document, id: NodeId) -> Vec<NodeId> {
let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return Vec::new() };
let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|n| &n.kind) else { return Vec::new() };
let swaps =
defs.iter().filter(|(_, d)| matches!(d, PropDef::InstanceSwap { .. })).filter_map(|(k, _)| props.get(k).and_then(crate::params::id_of));
std::iter::once(*component).chain(swaps).filter(|c| is_component(doc, *c)).collect()
}
fn graph(doc: &Document) -> Graph {
let mut g = Graph::new();
for n in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) {
let shown = shown(doc, n.id);
for h in hosts(doc, n.id) {
g.entry(h).or_default().extend(shown.iter().copied());
}
}
g
}
/// A loop in `g`, its first component repeated last (`A, B, A`).
fn find_cycle(g: &Graph) -> Option<Vec<NodeId>> {
let next = |u: &NodeId| -> Vec<NodeId> { g.get(u).map(|s| s.iter().rev().copied().collect()).unwrap_or_default() };
let mut done: BTreeSet<NodeId> = BTreeSet::new();
for start in g.keys() {
if done.contains(start) {
continue;
}
// Depth first: the path so far, and the components each step on it has left to visit.
let mut path = vec![*start];
let mut todo = vec![next(start)];
while let Some(left) = todo.last_mut() {
match left.pop() {
Some(u) if path.contains(&u) => {
let at = path.iter().position(|p| *p == u)?;
let mut cycle = path.split_off(at);
cycle.push(u);
return Some(cycle);
}
Some(u) if done.contains(&u) => {}
Some(u) => {
todo.push(next(&u));
path.push(u);
}
None => {
todo.pop();
done.extend(path.pop());
}
}
}
}
None
}
/// A loop of components whose mains contain each other's instances, if the document has one.
pub fn component_cycle(doc: &Document) -> Option<Vec<NodeId>> {
find_cycle(&graph(doc))
}
/// A loop as its names: `Card → Button → Card`.
fn cycle_names(doc: &Document, cycle: &[NodeId]) -> String {
cycle.iter().map(|u| doc.node(*u).map_or_else(|| u.to_string(), |n| format!("\"{}\"", n.name))).collect::<Vec<_>>().join(" → ")
}
/// Whether an instance of `component` under `parent` would contain itself: `parent` is (in) a
/// main that instance shows, directly or through other components.
pub fn would_contain_itself(doc: &Document, component: NodeId, parent: NodeId) -> bool {
let hosts = hosts(doc, parent);
if hosts.is_empty() {
return false;
}
let g = graph(doc);
let mut seen = BTreeSet::new();
let mut todo = vec![component];
while let Some(u) = todo.pop() {
if hosts.contains(&u) {
return true;
}
if seen.insert(u) {
todo.extend(g.get(&u).into_iter().flatten().copied());
}
}
false
}
/// Why an instance of `component` can't go where it would contain itself.
pub fn contains_itself_error(doc: &Document, component: NodeId) -> String {
let name = doc.node(component).map(|n| n.name.clone()).unwrap_or_default();
format!("an instance of \"{name}\" can't go inside \"{name}\" or a component it contains: a component can't contain itself")
}
/// The loop `after` has and `before` didn't, if layers `touched` (edited, added or removed) closed
/// one: only layers in main components can.
pub fn new_cycle<'a>(before: &Document, after: &Document, mut touched: impl Iterator<Item = &'a NodeId>) -> Option<String> {
if !touched.any(|d| !hosts(after, *d).is_empty()) {
return None;
}
cycle_text(after).filter(|_| component_cycle(before).is_none())
}
/// The document's loop of components containing each other's instances ([`component_cycle`]),
/// as its names.
pub fn cycle_text(doc: &Document) -> Option<String> {
component_cycle(doc).map(|c| cycle_names(doc, &c))
}
/// Refuse an edit that makes components contain each other's instances (it is rolled back). A
/// document that already held a loop stays editable.
pub fn check_component_cycles(before: &Document, after: &Document) -> Result<(), String> {
match new_cycle(before, after, after.dirty().iter()) {
Some(names) => Err(format!("a component can't contain an instance of itself, directly or through other components: {names}")),
None => Ok(()),
}
}
/// The instance to detach to cut `from → to` out of a loop: one in a main of `from` showing `to`,
/// preferably one of the main's own (not a layer of another instance, else its outer instance).
fn cut_point(doc: &Document, from: NodeId, to: NodeId) -> Option<NodeId> {
let found: Vec<NodeId> = doc
.nodes()
.filter(|n| matches!(n.kind, NodeKind::Instance { .. }))
.map(|n| n.id)
.filter(|i| shown(doc, *i).contains(&to) && hosts(doc, *i).contains(&from))
.collect();
found.iter().copied().find(|i| !inside_instance(doc, *i)).or_else(|| found.first().and_then(|i| root_instance(doc, *i)))
}
/// Detach instance `top` and every instance inside it: its layers stay as they are, linked to no
/// main.
fn detach_all(doc: &mut Document, top: NodeId) {
for d in doc.subtree(top) {
let modes = match doc.node(d).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => doc.node(*component).map(|m| m.var_modes.clone()),
_ => None,
};
let Some(n) = doc.node_mut(d) else { continue };
if let Some(modes) = modes {
n.kind = NodeKind::Frame;
n.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
for (c, m) in modes {
n.var_modes.entry(c).or_insert(m);
}
}
n.source = None;
n.overrides.clear();
n.prop_refs.clear();
}
}
/// Break every loop of components containing each other's instances (a file from elsewhere, or
/// written before they were refused): an instance closing each loop is detached, and the instances
/// of its main rebuild without it. Returns a note for each.
pub fn break_component_cycles(doc: &mut Document) -> Vec<String> {
let mut notes = Vec::new();
// Each pass detaches an instance: never more passes than there are layers.
for _ in 0..doc.nodes().count() {
let Some(cycle) = component_cycle(doc) else { break };
let (Some(from), Some(to)) = (cycle.iter().rev().nth(1).copied(), cycle.last().copied()) else { break };
let Some(cut) = cut_point(doc, from, to) else { break };
let name = doc.node(cut).map(|n| n.name.clone()).unwrap_or_default();
detach_all(doc, cut);
rebuild_instances(doc, &BTreeSet::from([from]));
notes.push(format!("\"{name}\" was detached: components can't contain their own instances ({})", cycle_names(doc, &cycle)));
}
notes
}
/// The layers of `component` an instance `inst` of it copies, in pre-order: all of them but those
/// inside a nested instance of a component being expanded already (its own, or a main `inst` is
/// in), which would show itself again. `None` when `inst` itself is in its own main.
pub(super) fn expansion_layers(doc: &Document, inst: NodeId, component: NodeId) -> Option<Vec<NodeId>> {
let mut stops = hosts(doc, inst);
if !stops.insert(component) {
return None;
}
let repeats = |kind: &NodeKind| matches!(kind, NodeKind::Instance { component: c, .. } if stops.contains(c));
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(component).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
while let Some(id) = stack.pop() {
let Some(n) = doc.node(id) else { continue };
out.push(id);
if out.len() > 5_000_000 {
break;
}
if !repeats(&n.kind) {
stack.extend(n.children.iter().rev());
}
}
Some(out)
}
@@ -1,3 +1,4 @@
use super::cycles::{contains_itself_error, would_contain_itself};
use super::diff::{binding_group, bindings_of};
use super::sync::{insert_linked, sync_instance_depth};
use super::{
@@ -16,8 +17,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
if !matches!(comp.kind, NodeKind::Component { .. }) {
return Err(DocError::Invalid(format!("{component} is not a component")));
}
if doc.is_ancestor(component, parent) {
return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
if would_contain_itself(doc, component, parent) {
return Err(DocError::Invalid(contains_itself_error(doc, component)));
}
let id = doc.alloc_id();
let mut root = comp.clone();
+1 -1
View File
@@ -500,7 +500,7 @@ fn sized_own(n: &Node) -> bool {
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
/// outwards), each outermost instance once, inner components first.
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
pub(super) fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
if comps.is_empty() {
return;
}
@@ -7,7 +7,7 @@ fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId
}
fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name)
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }, name)
}
fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId {
+101 -60
View File
@@ -1,3 +1,4 @@
use super::cycles::expansion_layers;
use super::diff::diff_groups;
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
use super::{
@@ -23,26 +24,76 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
// one layer mirrors it) and, only when those don't match, by name path.
let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
// Never a copy of itself inside it (components containing each other).
let Some(src) = expansion_layers(doc, inst, component) else { return };
let Matched { mut map, used, edited, fresh } = match_layers(doc, inst, component, &src, &mut old, &by_source, &by_src);
for c in root.children.clone() {
let _ = doc.remove(c);
}
for s in fresh {
let nid = doc.alloc_id();
map.insert(s, nid);
}
let Copies { nodes: new_nodes, nested, mut remeasure } = copy_layers(doc, inst, &src, &map, &old, &edited, eff_props);
for n in new_nodes {
let (id, parent) = (n.id, n.parent);
if insert_linked(doc, n).is_err()
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
{
p.children.retain(|c| *c != id);
}
}
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
}
// A resized instance: children follow their constraints (auto layout re-flows later).
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
remeasure = map.values().copied().collect();
}
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
remeasure_text(doc, remeasure);
rebuild_nested(doc, nested, &old, &used, depth);
}
/// Which instance layer each main layer a rebuild copies becomes.
struct Matched {
/// Main layer → its copy's id (the matching old layer's, so a layer the main moved to another
/// parent keeps its id and overrides); the main itself → the instance.
map: BTreeMap<NodeId, NodeId>,
/// The old layers reused.
used: BTreeSet<NodeId>,
/// Slots holding the instance's own content (their main layers' default content isn't copied).
edited: BTreeSet<NodeId>,
/// Main layers no old layer matches: their copies get new ids.
fresh: Vec<NodeId>,
}
/// Match the main layers `src` of instance `inst`'s main `component` to its old layers: by
/// (parent, source), by source alone (when only one layer mirrors it) and, only when those don't
/// match, by name path. Old layers matched across variants drop what the two variants differ in.
fn match_layers(
doc: &Document,
inst: NodeId,
component: NodeId,
src: &[NodeId],
old: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
by_source: &BTreeMap<(NodeId, NodeId), NodeId>,
by_src: &BTreeMap<NodeId, Option<NodeId>>,
) -> Matched {
let mut by_path: Option<BTreeMap<String, NodeId>> = None;
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
// parent keeps its id and overrides).
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
map.insert(component, inst);
let mut used: BTreeSet<NodeId> = BTreeSet::new();
// Slots holding the instance's own content (new ids), and main layers not copied: the
// default content of those slots.
let mut edited: BTreeSet<NodeId> = BTreeSet::new();
let mut m = Matched { map: BTreeMap::from([(component, inst)]), used: BTreeSet::new(), edited: BTreeSet::new(), fresh: Vec::new() };
let mut skipped: BTreeSet<NodeId> = BTreeSet::new();
let mut fresh_ids = Vec::new();
for s in &src {
for s in src {
let Some(sn) = doc.node(*s) else { continue };
if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
if sn.parent.is_some_and(|p| skipped.contains(&p) || m.map.get(&p).is_some_and(|pn| m.edited.contains(pn))) {
skipped.insert(*s);
continue;
}
let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
let parent_new = sn.parent.and_then(|p| m.map.get(&p).copied());
let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
if found.is_none() {
let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
@@ -50,37 +101,47 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
found = mains.get(s).and_then(|p| paths.get(p)).copied();
}
let nid = match found {
Some(o) if used.insert(o) => o,
Some(o) if m.used.insert(o) => o,
_ => {
fresh_ids.push(*s);
m.fresh.push(*s);
continue;
}
};
if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(m));
if let Some(kept) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(kept));
}
if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
edited.insert(nid);
m.edited.insert(nid);
}
map.insert(*s, nid);
m.map.insert(*s, nid);
}
for c in root.children.clone() {
let _ = doc.remove(c);
}
for s in fresh_ids {
let nid = doc.alloc_id();
map.insert(s, nid);
}
// Build the copies (pre-order: parents first).
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
enclosing.insert(inst, vec![inst]);
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::new();
inst_props.insert(inst, eff_props);
let mut nested = Vec::new();
let mut new_nodes = Vec::new();
// Text layers showing other text or style than their main layer (to re-measure).
let mut remeasure = Vec::new();
for s in &src {
m
}
/// A rebuild's copies of the main's layers, pre-order (parents first).
struct Copies {
nodes: Vec<Node>,
/// Nested instances showing another main than their main layer (swapped): rebuilt from it.
nested: Vec<NodeId>,
/// Text layers showing other text or style than their main layer (to re-measure).
remeasure: Vec<NodeId>,
}
/// Copy main layers `src` into instance `inst` as `map` names them, with the overrides of the
/// `old` layers they replace and the component property values (`props`: the instance's).
fn copy_layers(
doc: &Document,
inst: NodeId,
src: &[NodeId],
map: &BTreeMap<NodeId, NodeId>,
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
edited: &BTreeSet<NodeId>,
props: BTreeMap<String, Value>,
) -> Copies {
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::from([(inst, vec![inst])]);
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::from([(inst, props)]);
let mut out = Copies { nodes: Vec::new(), nested: Vec::new(), remeasure: Vec::new() };
for s in src {
let Some(orig) = doc.node(*s) else { continue };
let Some(&nid) = map.get(s) else { continue };
let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
@@ -113,7 +174,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
e.push(nid);
enclosing.insert(nid, e);
if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) {
nested.push(nid);
out.nested.push(nid);
}
} else {
enclosing.insert(nid, enc);
@@ -121,31 +182,11 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind)
&& a != b
{
remeasure.push(nid);
out.remeasure.push(nid);
}
new_nodes.push(n);
out.nodes.push(n);
}
for n in new_nodes {
let (id, parent) = (n.id, n.parent);
if insert_linked(doc, n).is_err()
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
{
p.children.retain(|c| *c != id);
}
}
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
}
// A resized instance: children follow their constraints (auto layout re-flows later).
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
remeasure = map.values().copied().collect();
}
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
remeasure_text(doc, remeasure);
rebuild_nested(doc, nested, &old, &used, depth);
out
}
/// Geometry overrides of children win over constraints.
+2 -1
View File
@@ -473,7 +473,7 @@ fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>,
insert_prop(o, options, "componentProperties", || json!(props));
}
}
NodeKind::Component { description, props } => {
NodeKind::Component { description, props, .. } => {
if !description.is_empty() {
insert_prop(o, options, "description", || json!(description));
}
@@ -539,6 +539,7 @@ pub fn document_with_options(s: &Session, depth: usize, all_pages: bool, options
})
.collect();
json!({
"id": ds.uid,
"name": doc.name,
"kind": doc.kind.id(),
"path": ds.path,
@@ -177,3 +177,76 @@ fn reattach_survives_junk() {
assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_))));
assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite()));
}
/// The connector on the page, if any (the one being drawn shows in the document).
fn the_connector(s: &Session) -> Option<figcraft_doc::Node> {
s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).cloned()
}
#[test]
fn the_connector_being_drawn_is_the_one_made() {
for style in ["straight", "elbow", "curved"] {
for to in [Point::new(350.0, 250.0), Point::new(700.0, 600.0)] {
let mut s = board();
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
let (_, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
s.execute("tool.connector", &json!({"style": style})).unwrap();
let undo = s.undo_depth().0;
let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0);
s.pointer(&ev(PointerKind::Down, Point::new(50.0, 50.0))).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(200.0, 150.0))).unwrap();
s.pointer(&ev(PointerKind::Move, to)).unwrap();
let drawing = the_connector(&s).unwrap_or_else(|| panic!("{style}: the connector shows while dragged"));
let ov = &s.tool.overlay;
if to.x < 400.0 {
// Over b: its four attach points, the one the end is at lit, and b outlined.
assert_eq!(ov.drop_target, Some(b));
let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect();
let end = *drawn(&s, drawing.id).last().unwrap();
assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}");
} else {
assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end");
}
assert!(s.tool.overlay.rubber.is_none(), "no rubber band: the connector itself shows");
s.pointer(&ev(PointerKind::Up, to)).unwrap();
let made = the_connector(&s).unwrap();
assert_eq!(made.kind, drawing.kind, "{style} to {to:?}: the same path");
assert_eq!((made.transform, made.width, made.height), (drawing.transform, drawing.width, drawing.height));
assert_eq!((&made.strokes, &made.stroke, &made.board), (&drawing.strokes, &drawing.stroke, &drawing.board), "line, caps and ends");
assert_eq!(s.undo_depth().0, undo + 1, "one undo step");
assert!(s.tool.overlay.attach.is_empty());
}
}
// Escape mid-drag: the preview goes and nothing is made.
let mut s = board();
s.execute("tool.connector", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 200.0, 0.0, 1.0)).unwrap();
assert!(the_connector(&s).is_some());
s.escape().unwrap();
assert!(the_connector(&s).is_none());
}
#[test]
fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
let mut s = board();
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
let (a, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
s.execute("tool.connector", &json!({"style": "straight"})).unwrap();
let ev = |k, x, y| PointerEvent::at(k, x, y, 1.0);
// Hovering: the object's attach points, lit only near one.
s.pointer(&ev(PointerKind::Hover, 50.0, 50.0)).unwrap();
assert_eq!(s.tool.overlay.attach.len(), 4);
assert!(s.tool.overlay.attach.iter().all(|a| !a.1));
s.pointer(&ev(PointerKind::Hover, 50.0, 3.0)).unwrap();
assert_eq!(s.tool.overlay.attach.iter().filter(|a| a.1).map(|a| a.0).collect::<Vec<_>>(), vec![[50.0, 0.0]]);
s.pointer(&ev(PointerKind::Hover, 900.0, 900.0)).unwrap();
assert!(s.tool.overlay.attach.is_empty());
// From near a's right middle to near b's top middle: those sides, kept.
s.pointer(&ev(PointerKind::Down, 97.0, 50.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap();
let k = the_connector(&s).unwrap().id;
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top }));
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
}
+45
View File
@@ -0,0 +1,45 @@
//! The board grid's settings: kept with the preferences, every value held within its range, and a
//! settings file edited by hand never costing the other preferences.
use serde_json::json;
use crate::board_grid::{BoardGrid, GridStyle};
use crate::{Prefs, Session};
#[test]
fn board_grid_sets_and_reads_back_the_preferences() {
let mut s = Session::new();
let r = s.execute("board.grid", &json!({})).unwrap();
assert_eq!(r, json!({"style": "dots", "spacing": 24.0, "major": 5, "size": 1.0, "contrast": 1.0}));
let r = s.execute("board.grid", &json!({"style": "graph", "spacing": 8, "major": 10, "contrast": 2.5})).unwrap();
assert_eq!(r["style"], "graph");
assert_eq!(s.prefs.board_grid, BoardGrid { style: GridStyle::Graph, spacing: 8.0, major: 10, size: 1.0, contrast: 2.5 });
// Out of range: the nearest end.
let r = s.execute("board.grid", &json!({"spacing": 1e9, "major": 1, "size": 0, "contrast": 99})).unwrap();
assert_eq!(r, json!({"style": "graph", "spacing": 512.0, "major": 2, "size": 0.5, "contrast": 4.0}));
// Refused, and nothing changes.
let before = s.prefs.board_grid.clone();
for p in [json!({"style": "hexagons"}), json!({"style": 3}), json!({"spacing": "wide"}), json!({"contrast": [1]})] {
assert!(s.execute("board.grid", &p).is_err(), "{p}");
}
assert_eq!(s.prefs.board_grid, before);
// Off, then back to the default.
assert_eq!(s.execute("board.grid", &json!({"style": "None"})).unwrap()["style"], "none");
s.execute("board.grid", &json!({"reset": true})).unwrap();
assert_eq!(s.prefs.board_grid, BoardGrid::default());
}
#[test]
fn a_saved_board_grid_survives_hand_edits() {
let p = Prefs { board_grid: BoardGrid { style: GridStyle::Lines, spacing: 32.0, major: 4, size: 2.0, contrast: 1.5 }, ..Prefs::default() };
let back: Prefs = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
assert_eq!(back.board_grid, p.board_grid);
// Unreadable: the default grid, and the other preferences stay.
let back: Prefs = serde_json::from_value(json!({"nudge_small": 3.0, "board_grid": {"style": "zigzag"}})).unwrap();
assert_eq!((back.nudge_small, back.board_grid), (3.0, BoardGrid::default()));
let back: Prefs = serde_json::from_value(json!({"board_grid": "dots"})).unwrap();
assert_eq!(back.board_grid, BoardGrid::default());
// Out of range: clamped; missing fields: their defaults.
let back: Prefs = serde_json::from_value(json!({"board_grid": {"style": "graph", "spacing": -5, "major": 400}})).unwrap();
assert_eq!(back.board_grid, BoardGrid { style: GridStyle::Graph, spacing: 4.0, major: 12, ..BoardGrid::default() });
}
+1 -1
View File
@@ -207,7 +207,7 @@ fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() {
click(&mut s, 100.0, 100.0);
s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap();
assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging");
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))), "nothing previewed: none would be made");
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap();
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))));
// From the shape out to empty canvas: a connector to a free point.
+1
View File
@@ -1,3 +1,4 @@
pub mod board;
pub mod board_grid;
pub mod board_table;
pub mod board_templates;
+29 -12
View File
@@ -48,11 +48,26 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => routing_of(cmd, &v)?,
None => s.tool.board.routing,
};
let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None);
let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow);
let c = Connector { from, to, routing };
let start = cap_of(cmd, p, "startCap")?.unwrap_or(CAPS.0);
let end = cap_of(cmd, p, "endCap")?.unwrap_or(CAPS.1);
let page = s.page()?;
let doc = s.doc_mut()?;
let id = make(doc, page, Connector { from, to, routing }, (start, end))?;
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
}
s.set_selection(vec![id]);
Ok(out)
}
/// A new connector's (start, end) caps.
pub(super) const CAPS: (StrokeCap, StrokeCap) = (StrokeCap::None, StrokeCap::TriangleArrow);
/// Connector `c` added to `page` with its line, `caps` (start, end) and route: what
/// `board.connector` makes, and the connector tool shows while it is dragged.
pub(super) fn make(doc: &mut Document, page: NodeId, c: Connector, caps: (StrokeCap, StrokeCap)) -> Result<NodeId> {
let cmd = "board.connector";
let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?;
if pts.windows(2).all(|w| w.first() == w.get(1)) {
return Err(bad(cmd, "the ends are in the same place"));
@@ -62,17 +77,11 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
n.strokes = vec![Paint::solid(LINE)];
n.stroke.weight = LINE_WEIGHT;
n.stroke.join = StrokeJoin::Round;
n.stroke.cap_start = start;
n.stroke.cap_end = end;
(n.stroke.cap_start, n.stroke.cap_end) = caps;
n.board = Some(BoardItem::Connector(c));
})?;
place_route(doc, id, &pts, routing);
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
}
s.set_selection(vec![id]);
Ok(out)
place_route(doc, id, &pts, c.routing);
Ok(id)
}
/// The connector `id` names: a connector, or the label on one.
@@ -121,6 +130,14 @@ pub(super) fn end_json(e: End) -> Value {
}
}
/// The side a connector end names, as the commands take it (none for a free point).
pub(super) fn side_json(e: End) -> Value {
match e {
End::Node { side, .. } => json!(side),
End::Point { .. } => Value::Null,
}
}
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
match &doc.node(id)?.board {
+82 -32
View File
@@ -1,12 +1,18 @@
use super::connectors::{end_json, other_end};
use super::derived::ends_now;
use super::connectors::{CAPS, end_json, make, other_end, side_json};
use super::derived::{ends_now, route};
use super::edit::label_of;
use super::items::edit_text;
use super::{
BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of,
is_board, json,
BoardItem, Connector, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId,
id_of, is_board, json,
};
/// How near (screen pixels) the pointer must be to the middle of an object's side for a connector
/// end to attach there rather than to the side facing the other end.
const ATTACH_PX: f64 = 10.0;
/// The sides an object takes a connector end at (their middles are its attach points).
const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
// ---------------------------------------------------------------------------------------------
// The board tools.
@@ -51,7 +57,8 @@ impl Session {
ToolId::Connector => {
let from = self.end_at(ev);
self.tool.gesture = Gesture::Connect { from, start: ev.p() };
Ok(())
// While it is dragged the connector shows as it will be made (a preview).
self.begin_interaction("Connector")
}
ToolId::Eraser => {
self.begin_interaction("Erase")?;
@@ -69,47 +76,87 @@ impl Session {
}
}
/// What a connector end at the pointer attaches to: the object there, or the point.
/// What a connector end at the pointer attaches to: the object there (at the side whose middle
/// is within [`ATTACH_PX`], else the side facing the other end), or the point.
fn end_at(&self, ev: &PointerEvent) -> End {
let zoom = ev.zoom.max(1e-6);
let hit = (|| {
let doc = self.doc().ok()?;
let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?;
let ds = self.ds().ok()?;
// Under the connector being dragged out (previewed in the document), not on it.
let doc = ds.interaction.as_ref().map_or(&*ds.doc, |it| &*it.doc);
let leaf = self.hit(doc, ds.page, ev.p(), 2.0 / zoom)?;
let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false);
let n = doc.node(t)?;
(!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t)
if matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink)) {
return None;
}
let b = doc.world_bounds(t);
let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX);
let side = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map_or(Side::Auto, |(s, _)| s);
Some(End::Node { node: t, side })
})();
match hit {
Some(node) => End::Node { node, side: Side::Auto },
None => End::Point { x: ev.x, y: ev.y },
}
hit.unwrap_or(End::Point { x: ev.x, y: ev.y })
}
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
let a = match from {
End::Point { x, y } => Point::new(x, y),
End::Node { .. } => start,
/// Where a connector dragged from `from` (pressed at `start`) to the pointer ends: none for a
/// click, or back on the object it started from (nothing is made).
fn drag_end(&self, ev: &PointerEvent, from: End, start: Point) -> Option<End> {
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return None;
}
let to = self.end_at(ev);
(to.node().is_none() || to.node() != from.node()).then_some(to)
}
/// The object `end` is on (if any) outlined, with its attach points; the one the end is at
/// (`at`, else its named side's) lit.
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
let attach = match end {
Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
}),
_ => None,
};
self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]);
self.tool.overlay.drop_target = from.node();
self.tool.overlay.attach = attach.unwrap_or_default();
self.tool.overlay.drop_target = end.and_then(|e| e.node());
}
/// The pointer moving with the connector tool and no button down: the attach points under it.
pub(crate) fn connector_hover(&mut self, ev: &PointerEvent) {
let end = self.doc().is_ok_and(is_board).then(|| self.end_at(ev));
self.show_attach(end, None);
}
/// The connector being dragged out, drawn as it will be made (a preview) to the attach point
/// or free point under the pointer.
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
self.tool.gesture = Gesture::Connect { from, start };
let c = self.drag_end(ev, from, start).map(|to| Connector { from, to, routing: self.tool.board.routing });
if let Ok(page) = self.page() {
// One that can't be placed shows nothing, as none would be made.
let _ = self.preview(|d, _| {
if let Some(c) = c {
let _ = make(d, page, c, CAPS);
}
Ok(())
});
}
let at = c.and_then(|c| route(self.doc().ok()?, &c)).and_then(|pts| pts.last().copied());
self.show_attach(c.map(|c| c.to), at);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
}
pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
self.tool.overlay.drop_target = None;
// A click, not a drag: nothing to join.
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
let to = self.end_at(ev);
if to.node().is_some() && to.node() == from.node() {
return Ok(());
}
self.show_attach(None, None);
// The preview goes: the command makes the connector, one undo step.
self.cancel_interaction()?;
let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) };
let style = self.tool.board.routing.id();
self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?;
let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style});
self.execute("board.connector", &p)?;
self.board_placed();
Ok(())
}
@@ -122,7 +169,8 @@ impl Session {
let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a });
let other = other_end(doc, id, from).and_then(|e| e.node());
self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]);
self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other);
let target = Some(self.end_at(ev)).filter(|e| e.node().is_none() || e.node() != other);
self.show_attach(target, None);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
@@ -132,6 +180,7 @@ impl Session {
/// A click, or a drop on the object the other end is on, changes nothing.
pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
self.show_attach(None, None);
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
@@ -140,7 +189,8 @@ impl Session {
if target.node().is_some() && target.node() == other {
return Ok(());
}
self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?;
let end = if from { "from" } else { "to" };
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?;
Ok(())
}
+138
View File
@@ -0,0 +1,138 @@
//! The board canvas's background grid, kept with the preferences: dots (the default), lines, or
//! graph paper (a strong line every few cells over fainter ones, like Japanese 方眼紙 section
//! paper), how far apart, how large and how strong its marks are, or no grid at all. The canvas
//! thins it out as it zooms out, so it never turns into a wash of marks.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::{boolean, get, num};
use crate::{Result, Session, bad, spec};
/// What the grid draws.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GridStyle {
#[default]
Dots,
Lines,
/// Lines with every `major`th one strong (section paper).
Graph,
None,
}
impl GridStyle {
pub const ALL: [GridStyle; 4] = [GridStyle::Dots, GridStyle::Lines, GridStyle::Graph, GridStyle::None];
pub fn id(self) -> &'static str {
match self {
GridStyle::Dots => "dots",
GridStyle::Lines => "lines",
GridStyle::Graph => "graph",
GridStyle::None => "none",
}
}
pub fn from_id(id: &str) -> Option<GridStyle> {
GridStyle::ALL.into_iter().find(|s| s.id().eq_ignore_ascii_case(id.trim()))
}
}
/// The spacing's range (page units between neighbouring marks at 100 %).
pub const SPACING: (f64, f64) = (4.0, 512.0);
/// Graph paper's cells per strong line.
pub const MAJOR: (u32, u32) = (2, 12);
/// The marks' size and strength, relative to the usual (1).
pub const SIZE: (f64, f64) = (0.5, 4.0);
pub const CONTRAST: (f64, f64) = (0.25, 4.0);
/// The board grid's settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct BoardGrid {
pub style: GridStyle,
/// Page units between neighbouring marks at 100 % (zoomed out, every second, fourth… stays).
pub spacing: f64,
/// Graph paper: a strong line every `major` cells.
pub major: u32,
/// How large the marks are: 1 is about a point and a half across for dots, a point for lines.
pub size: f64,
/// How strong the marks are against the page: 1 is the usual, subtle grid.
pub contrast: f64,
}
impl Default for BoardGrid {
fn default() -> Self {
Self { style: GridStyle::Dots, spacing: 24.0, major: 5, size: 1.0, contrast: 1.0 }
}
}
/// `v` kept within `range`; `d` when it is not a finite number.
fn within(v: f64, (lo, hi): (f64, f64), d: f64) -> f64 {
if v.is_finite() { v.clamp(lo, hi) } else { d }
}
impl BoardGrid {
/// Every value back within its range (a settings file edited by hand).
pub fn sanitized(mut self) -> Self {
let d = Self::default();
self.spacing = within(self.spacing, SPACING, d.spacing);
self.major = self.major.clamp(MAJOR.0, MAJOR.1);
self.size = within(self.size, SIZE, d.size);
self.contrast = within(self.contrast, CONTRAST, d.contrast);
self
}
pub fn to_json(&self) -> Value {
json!({"style": self.style.id(), "spacing": self.spacing, "major": self.major, "size": self.size, "contrast": self.contrast})
}
}
/// Reads the saved grid: anything unreadable is the default grid and values out of range are
/// clamped, so a settings file edited by hand never costs the other preferences.
pub(crate) fn read<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<BoardGrid, D::Error> {
Ok(serde_json::from_value::<BoardGrid>(Value::deserialize(d)?).unwrap_or_default().sanitized())
}
const CMD: &str = "board.grid";
/// A number param of `board.grid`: `None` when omitted, an error when not a finite number.
fn number(p: &Value, key: &str) -> Result<Option<f64>> {
match get(p, key) {
None | Some(Value::Null) => Ok(None),
Some(_) => num(p, key).filter(|v| v.is_finite()).map(Some).ok_or_else(|| bad(CMD, format!("`{key}` must be a number"))),
}
}
fn grid_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let mut g = if boolean(p, "reset") == Some(true) { BoardGrid::default() } else { s.prefs.board_grid.clone() };
if let Some(v) = get(p, "style").filter(|v| !v.is_null()) {
let id = v.as_str().unwrap_or_default();
g.style = GridStyle::from_id(id).ok_or_else(|| bad(CMD, "`style` is dots, lines, graph or none"))?;
}
if let Some(v) = number(p, "spacing")? {
g.spacing = v;
}
if let Some(v) = number(p, "major")? {
g.major = v.round().clamp(f64::from(MAJOR.0), f64::from(MAJOR.1)) as u32;
}
if let Some(v) = number(p, "size")? {
g.size = v;
}
if let Some(v) = number(p, "contrast")? {
g.contrast = v;
}
s.prefs.board_grid = g.sanitized();
Ok(s.prefs.board_grid.to_json())
}
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"board.grid",
"Board grid",
"{style?: dots|lines|graph|none (graph: section paper, a strong line every `major` cells), spacing? (4–512 page units at 100 %), major? (2–12), size? (0.5–4, 1 the usual), contrast? (0.25–4, 1 the usual), reset? (back to the default first); omit all: just read it} → {style, spacing, major, size, contrast} the board canvas's background grid, kept with the preferences; zoomed out it thins out",
false,
grid_cmd
)]
}
+73
View File
@@ -9,6 +9,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox};
use serde::Serialize;
use crate::guides;
use crate::tools::SNAP_PX;
/// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label
/// (a distance) at its middle; `dashed` marks a measurement's extension line.
@@ -127,6 +128,56 @@ impl Targets {
t
}
/// Snap lines for a guide on `owner` (a page or a top-level frame): a page's top-level layers,
/// or a frame's own box, layout grids and descendants, in the owner's own space so a rotated
/// frame's guide snaps along its axes.
pub fn gather_for_guide(doc: &Document, page: NodeId, owner: NodeId) -> Targets {
let space = if owner == page { Affine::IDENTITY } else { doc.world_transform(owner) };
let to = space.inverse();
let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite());
let mut t = Targets { space, ..Default::default() };
if owner == page {
if let Some(p) = doc.node(page) {
for c in &p.children {
if doc.node(*c).is_some_and(|n| n.visible)
&& let Some(b) = bbox(*c)
{
t.add_box(b);
}
}
}
return t;
}
// The frame's own box and its layout grids.
if let Some(b) = bbox(owner) {
t.add_box(b);
let (gx, gy) = figcraft_layout::grid_snap_lines_in(doc, owner, to * doc.world_transform(owner));
for x in gx {
t.xs.push((x, b.y0, b.y1));
}
for y in gy {
t.ys.push((y, b.x0, b.x1));
}
}
// Everything in the frame. Nested layers count, so a guide over a group reaches its parts; a
// hidden layer hides its whole branch, so a visible child of a hidden group isn't a target.
let mut stack: Vec<(NodeId, bool)> = doc.node(owner).map(|n| n.children.iter().rev().map(|c| (*c, true)).collect()).unwrap_or_default();
let mut seen = 0;
while let Some((id, visible)) = stack.pop() {
seen += 1;
if seen > 5_000_000 {
break;
}
let Some(n) = doc.node(id) else { continue };
let visible = visible && n.visible;
if visible && let Some(b) = bbox(id) {
t.add_box(b);
}
stack.extend(n.children.iter().rev().map(|c| (*c, visible)));
}
t
}
/// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it.
pub fn add_box(&mut self, r: Rect) {
for x in [r.x0, r.center().x, r.x1] {
@@ -335,6 +386,16 @@ impl Targets {
(dx, dy, self.guides_spans(&xs, &ys))
}
/// The correction along one axis (x when `vertical`, else y) to the nearest target line within
/// `threshold`, in snap space. A guide moves on its own axis only, so the other is left alone.
pub fn snap_axis(&self, vertical: bool, offset: f64, threshold: f64) -> Option<f64> {
if !offset.is_finite() || !threshold.is_finite() || threshold < 0.0 {
return None;
}
let lines = if vertical { &self.xs } else { &self.ys };
lines.iter().map(|(v, _, _)| v - offset).filter(|d| d.is_finite() && d.abs() <= threshold).min_by(|a, b| a.abs().total_cmp(&b.abs()))
}
/// Guide lines for edges of `r` that coincide with targets.
pub fn guides_for(&self, r: Rect) -> Vec<Guide> {
let (xs, ys) = rect_spans(r);
@@ -363,6 +424,18 @@ impl Targets {
}
}
/// Snap a guide on `owner` (a page or a top-level frame) to the nearest layer edge or centre along
/// its own axis, within `SNAP_PX` screen pixels at `zoom`. Returns the new offset in `owner`'s
/// coordinates, or `None` when nothing is in reach.
pub fn snap_guide(doc: &Document, page: NodeId, owner: NodeId, vertical: bool, offset: f64, zoom: f64) -> Option<f64> {
// The guide moves along the owner's own axis (x for a vertical guide, y for a horizontal one),
// which the owner's transform may scale: convert the screen-pixel reach into owner units.
let [a, b, c, d, _, _] = doc.world_transform(owner).as_coeffs();
let axis = if vertical { a.hypot(b) } else { c.hypot(d) };
let threshold = SNAP_PX / (zoom.max(1e-6) * axis.max(1e-6));
Targets::gather_for_guide(doc, page, owner).snap_axis(vertical, offset, threshold).map(|d| offset + d).filter(|v| v.is_finite())
}
/// The parent of the moving layers (the first one's), or the page.
fn parent_of(doc: &Document, page: NodeId, moving: &[NodeId]) -> NodeId {
moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page)
+4 -1
View File
@@ -236,7 +236,7 @@ pub struct PointerEvent {
}
/// The pointer is still within [`DRAG_SLOP_PX`] of where it was pressed.
fn within_slop(ev: &PointerEvent, start: Point) -> bool {
pub(crate) fn within_slop(ev: &PointerEvent, start: Point) -> bool {
(ev.p() - start).hypot() * ev.zoom.max(1e-6) < DRAG_SLOP_PX
}
@@ -336,6 +336,9 @@ pub struct Overlay {
pub rubber_handle: Option<[f64; 2]>,
/// Frame/section the pointer is drawing or dropping into (highlighted).
pub drop_target: Option<NodeId>,
/// Connector tool: the attach points (side middles, page x, y) of the object under the pointer,
/// and whether the connector end is at each.
pub attach: Vec<([f64; 2], bool)>,
/// Auto layout insertion indicator (x0, y0, x1, y1).
pub insert_line: Option<[f64; 4]>,
/// The instance an Alt-drop of a component from the Assets panel would swap (highlighted).
@@ -257,6 +257,9 @@ impl Session {
}
}
self.tool.overlay.guides = guides;
if tool == ToolId::Connector {
self.connector_hover(ev);
}
let ds = self.ds_mut()?;
if ds.hover != hover {
ds.hover = hover;
+17 -12
View File
@@ -13,7 +13,7 @@ use super::regions::region_hit;
use super::transform::{self, BoxHit, PointBox};
use super::{EditPart, VHit, VTool, VectorEdit, edit, editing, hit, hit_any, local_offset, network, ops, points_of, selected_points, set_state};
use crate::snap::Targets;
use crate::tools::{Cursor, Gesture, Handle, PenCursor, PointerEvent, PointerKind};
use crate::tools::{Cursor, Gesture, Handle, PenCursor, PointerEvent, PointerKind, within_slop};
use crate::{Result, Session};
/// An in-progress edit-mode gesture (cheap to clone: snap targets are shared).
@@ -67,10 +67,13 @@ pub(crate) enum VGesture {
moved: bool,
smooth: bool,
},
/// Marquee and lasso: `base` is each edited layer's selection to add to.
/// Marquee and lasso: `base` is each edited layer's selection to add to. `leave` is a press
/// on another layer: released without a drag, it leaves edit mode and goes to the
/// normal tool.
Marquee {
start: Point,
base: Vec<EditPart>,
leave: Option<PointerEvent>,
},
Lasso {
points: Vec<Point>,
@@ -700,21 +703,18 @@ impl Session {
}
}
None => {
// A press on another layer (and not shift) leaves edit mode, so the normal tool
// takes it; empty canvas only clears the points and starts a marquee (a
// double-click or Escape leaves edit mode).
// Every drag here marquees (#1508). A click on another layer (and not shift)
// leaves edit mode, so the normal tool takes it; a click on empty canvas only
// clears the points (a double-click or Escape leaves edit mode).
let inside =
ve.parts().iter().any(|p| doc.world_render_bounds(p.id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p())));
let page = self.ds()?.page;
let other = !inside && self.hit(&doc, page, ev.p(), tol * 2.0).is_some();
if other && !ev.shift {
super::end(self)?;
return Ok(false);
}
if !ev.shift {
set_state(self, VectorEdit::clear_selection)?;
}
self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift) });
let leave = (other && !ev.shift).then(|| ev.clone());
self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift), leave });
}
},
VTool::Bend => match h {
@@ -738,7 +738,7 @@ impl Session {
self.begin_interaction("Bend")?;
self.vgesture(VGesture::BendSegment { seg, t });
}
None => self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift) }),
None => self.vgesture(VGesture::Marquee { start: ev.p(), base: Self::select_base(ve, ev.shift), leave: None }),
},
VTool::Pen => {
let at = if h.is_none() && !ev.shift { self.snap_pen(ev, Some(id), &[])?.0 } else { ev.p() };
@@ -951,7 +951,7 @@ impl Session {
self.preview(|doc, _| edit(doc, id, |net| ops::make_smooth(net, v, d)))?;
self.vgesture(VGesture::BendVertex { v, origin, moved: true, smooth });
}
VGesture::Marquee { start, base } => {
VGesture::Marquee { start, base, .. } => {
let r = Rect::from_points(start, ev.p());
self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]);
self.select_parts(&base, |doc, id| super::vertices_in(doc, id, r), false)?;
@@ -1068,6 +1068,11 @@ impl Session {
VGesture::Handle { .. } | VGesture::BendSegment { .. } | VGesture::Pen { .. } | VGesture::BoxRotate { .. } => {
self.commit_interaction()?
}
VGesture::Marquee { start, leave: Some(press), .. } if within_slop(ev, start) => {
super::end(self)?;
self.pointer(&press)?;
return self.pointer(ev);
}
// The active layer moves to one with points selected when it has none.
VGesture::Marquee { .. } | VGesture::Lasso { .. } => set_state(self, |e| {
if e.vertices.is_empty()
+1
View File
@@ -117,6 +117,7 @@ fn registry() -> &'static Registry {
list.extend(crate::textfit::specs());
list.extend(crate::board::specs());
list.extend(facilitate::specs());
list.extend(crate::board_grid::specs());
list.extend(crate::board_table::specs());
list.extend(crate::board_templates::specs());
let mut index = HashMap::new();
+65 -18
View File
@@ -1,12 +1,13 @@
//! Comments: add, reply, edit, react, resolve, read state, delete, find and reveal comment threads.
//!
//! Messages are addressed by `reply` (an index into the replies; omitted: the thread's first
//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only
//! a message's author edits or deletes it, and only the thread's author deletes the thread.
//! message). `author` is who is acting (default: the AI agent the command runs for, else the name
//! from Preferences, `pref.userName`): only a message's author edits or deletes it, and only the
//! thread's author deletes the thread.
use std::fmt::Write as _;
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom};
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom, mentions};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
@@ -16,6 +17,12 @@ use crate::{Session, bad, spec};
/// The name comments are signed with until the user sets theirs.
pub const DEFAULT_AUTHOR: &str = "You";
/// The name of an AI agent that gave none.
pub const DEFAULT_AGENT: &str = "AI agent";
/// Most characters of an agent's own name in its comments.
const MAX_AGENT_NAME: usize = 64;
/// Links to a thread: `figcraft://comment/<id>`.
const LINK_PREFIX: &str = "figcraft://comment/";
@@ -260,9 +267,27 @@ pub fn user_name(s: &Session) -> String {
if n.is_empty() { DEFAULT_AUTHOR.into() } else { n.to_string() }
}
/// Who is acting: `author` from the params, else the user.
/// How an AI agent's comments are signed: its client's name, marked so it never reads as a
/// person's ("Claude" → "Claude (agent)"), or [`DEFAULT_AGENT`] when it gave none.
pub fn agent_author(name: &str) -> String {
let n: String = name.chars().filter(|c| !c.is_control()).collect();
let n: String = n.trim().chars().take(MAX_AGENT_NAME).collect();
if n.is_empty() { DEFAULT_AGENT.into() } else { format!("{n} (agent)") }
}
/// Who acts when no `author` is given: the AI agent the command runs for, else the user.
pub fn acting_name(s: &Session) -> String {
s.agent.clone().unwrap_or_else(|| user_name(s))
}
/// Who is acting: `author` from the params, else [`acting_name`].
fn author(s: &Session, p: &Value) -> String {
string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| user_name(s))
string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| acting_name(s))
}
/// A new message by `who`, marked as an agent's when the command runs for one.
fn new_message(s: &Session, who: String, text: String) -> Message {
Message { agent: s.agent.is_some(), ..Message::new(who, text, now_secs()) }
}
fn text(cmd: &str, p: &Value) -> crate::Result<String> {
@@ -301,25 +326,42 @@ fn local(doc: &Document, anchor: Option<NodeId>, x: f64, y: f64) -> (f64, f64) {
}
}
fn message_json(m: &Message) -> Value {
/// The names an @mention resolves to: the file's people (`Document::comment_people`) and `user`,
/// as the thread view highlights them.
fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> {
people.iter().map(String::as_str).chain([user]).collect()
}
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
let mut named: Vec<Value> = Vec::new();
for (r, who) in mentions(&m.text, people) {
let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default();
let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))});
if !named.contains(&v) {
named.push(v);
}
}
json!({
"author": m.author, "text": m.text, "created": m.created, "edited": m.edited,
"author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited,
"reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::<Vec<_>>(),
"mentions": named,
})
}
/// JSON view of a comment as `user` sees it: its page position, frame, read state and link, the
/// first message's fields, and the replies.
pub fn comment_json(doc: &Document, c: &Comment, user: &str) -> Value {
/// first message's fields, and the replies; mentions resolve against `people` (`mentionable`).
pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
let (x, y) = c.position(doc);
let frame = frame_of(doc, c);
let mut v = message_json(&c.first);
let mut v = message_json(&c.first, people, user);
let extra = json!({
"id": c.id, "page": c.page, "pageName": doc.node(c.page).map(|n| n.name.as_str()),
"x": x, "y": y, "anchor": c.anchor,
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| n.name.as_str()),
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
"replies": c.replies.iter().map(message_json).collect::<Vec<_>>(),
"replies": c.replies.iter().map(|m| message_json(m, people, user)).collect::<Vec<_>>(),
});
if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) {
m.extend(e);
@@ -338,7 +380,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
"x": x, "y": y, "anchor": c.anchor,
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)),
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
"author": clip_chars(&c.first.author, 80), "preview": clip_chars(&c.first.text, 160),
"author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160),
"replies": c.replies.len(),
})
}
@@ -385,7 +427,8 @@ fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result<Value>
s.request_ui("comment.open", json!({"id": id}));
let doc = s.doc()?;
let c = doc.comment(id).ok_or_else(|| bad("comment.open", "no comment"))?;
Ok(comment_json(doc, c, who))
let people = doc.comment_people();
Ok(comment_json(doc, c, who, &mentionable(&people, who)))
}
/// A list's page (None: every page), filter, sort and search text from params.
@@ -565,7 +608,7 @@ pub fn specs() -> Vec<CommandSpec> {
x: lx,
y: ly,
anchor,
first: Message::new(who.clone(), text, now_secs()),
first: new_message(s, who.clone(), text),
resolved: false,
replies: Vec::new(),
read_by: None,
@@ -579,7 +622,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| {
let id = req_comment("comment.reply", s, p)?;
let who = author(s, p);
let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs());
let r = new_message(s, who.clone(), text("comment.reply", p)?);
let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.reply", "no comment"))?;
c.replies.push(r);
c.resolved = false;
@@ -758,7 +801,9 @@ pub fn specs() -> Vec<CommandSpec> {
let id = req_comment("comment.link", s, p)?;
Ok(json!({"link": link(id)}))
}),
spec!("comment.people", "People in comments", "{}", false, |s, _| { Ok(json!({"people": s.doc()?.comment_people(), "you": user_name(s)})) }),
spec!("comment.people", "People in comments", "{}", false, |s, _| {
Ok(json!({"people": s.doc()?.comment_people(), "you": acting_name(s)}))
}),
spec!(
"comment.list",
"List comments",
@@ -769,7 +814,9 @@ pub fn specs() -> Vec<CommandSpec> {
let (page, filter, sort, text) = list_params("comment.list", s, p)?;
let who = author(s, p);
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect();
let people = doc.comment_people();
let people = mentionable(&people, &who);
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
Ok(json!({"comments": list}))
}
),
@@ -817,7 +864,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
Some(_) => return Err(bad("comment.prompt", "`ids` must be an array of comment ids")),
};
let who = user_name(s);
let who = acting_name(s);
let selected: Vec<&Comment> = if let Some(ids) = ids {
let mut out = Vec::with_capacity(ids.len());
for id in ids {
+3 -3
View File
@@ -292,8 +292,8 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut()?;
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
let mut slot = crate::drop::slot(doc, page, pt);
// Never inside its own main component (or a copy of it).
if doc.is_ancestor(c, slot.parent) || slot.parent == c {
// Never inside its own main component, or one it contains (directly or through others).
if crate::components::would_contain_itself(doc, c, slot.parent) {
slot = crate::drop::DropSlot { parent: page, index: None, line: None };
}
let (w, h) = doc.node(c).map(|n| (n.width, n.height)).unwrap_or((0.0, 0.0));
@@ -342,7 +342,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut()?;
let n = doc.get_mut(target)?;
let was_group = matches!(n.kind, NodeKind::Group);
n.kind = NodeKind::Component { description: String::new(), props: Default::default() };
n.kind = NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() };
if was_group {
n.fills.clear();
n.clip = false;
+3 -3
View File
@@ -331,7 +331,7 @@ pub(super) fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
let Some(mut n) = doc.node(*d).cloned() else { continue };
if *d == i {
let defs = restore_prop_defs(doc, &props);
n.kind = NodeKind::Component { description: String::new(), props: defs };
n.kind = NodeKind::Component { description: String::new(), props: defs, prop_order: Vec::new() };
n.source = None;
n.overrides.clear();
n.prop_refs.clear();
@@ -376,8 +376,8 @@ pub(super) fn swap(s: &mut Session, p: &Value) -> Result<Value> {
/// Make instance `i` show main component `c`, keeping the values of properties with the same names
/// (the layers' overrides follow by name when the instance is rebuilt).
pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> {
if doc.is_ancestor(c, i) {
return Err("an instance can't show the component it is inside".into());
if crate::components::would_contain_itself(doc, c, i) {
return Err(crate::components::contains_itself_error(doc, c));
}
let defaults = doc.node(c).map(default_props).unwrap_or_default();
match doc.get_mut(i).map(|n| &mut n.kind) {
+24 -9
View File
@@ -1,3 +1,5 @@
use figcraft_doc::ordered_props;
use super::instance::preferred_arg;
use super::variants::{fit_set, wrap_in_set};
use super::{
@@ -6,6 +8,7 @@ use super::{
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
variant_options, variant_props, variant_set,
};
use crate::components::prop_list;
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
/// boolean property takes a boolean variable, a text or variant property a string one).
@@ -195,9 +198,22 @@ pub(super) fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap<String, PropDef>
}
}
fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap<String, PropDef>)) -> Result<()> {
if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind {
/// Change component `c`'s property definitions with `f`, keeping their order: the property
/// `rename` names (old, new) keeps its place, new ones go last.
fn set_prop_defs(doc: &mut Document, c: NodeId, rename: Option<(&str, &str)>, f: impl FnOnce(&mut BTreeMap<String, PropDef>)) -> Result<()> {
if let NodeKind::Component { props, prop_order, .. } = &mut doc.get_mut(c)?.kind {
let mut order: Vec<String> = ordered_props(props, prop_order)
.into_iter()
.map(|(k, _)| match rename {
Some((old, new)) if k == old => new.to_string(),
_ => k.clone(),
})
.collect();
f(props);
order.retain(|k| props.contains_key(k));
let added: Vec<String> = props.keys().filter(|k| !order.contains(k)).cloned().collect();
order.extend(added);
*prop_order = order;
}
Ok(())
}
@@ -280,7 +296,7 @@ pub(super) fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Valu
_ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")),
};
for c in &comps {
set_prop_defs(doc, *c, |m| {
set_prop_defs(doc, *c, None, |m| {
m.insert(name.clone(), def.clone());
})?;
// Existing instances get the default.
@@ -508,10 +524,9 @@ pub(super) fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
}
let key = new_name.clone().unwrap_or(name.clone());
let nd = new_def.clone();
let old = name.clone();
set_prop_defs(doc, *c, move |m| {
m.remove(&old);
m.insert(key, nd);
set_prop_defs(doc, *c, Some((&name, &key)), |m| {
m.remove(&name);
m.insert(key.clone(), nd);
})?;
if let Some(nn) = &new_name {
rename_prop_refs(doc, *c, &name, Some(nn))?;
@@ -547,7 +562,7 @@ pub(super) fn delete_prop(s: &mut Session, p: &Value) -> Result<Value> {
for c in &comps {
if prop_defs(doc, *c).contains_key(&name) {
found = true;
set_prop_defs(doc, *c, |m| {
set_prop_defs(doc, *c, None, |m| {
m.remove(&name);
})?;
rename_prop_refs(doc, *c, &name, None)?;
@@ -591,7 +606,7 @@ pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value {
}
// Slots: the frames (on a main) or this instance's copy, with whether it holds its own content.
let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) });
for (k, d) in prop_defs(doc, comp) {
for (k, d) in prop_list(doc, comp) {
let mut v = def_json(&k, &d);
if !takes_value(&d) {
let layers: Vec<NodeId> = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
+1 -1
View File
@@ -173,7 +173,7 @@ pub fn kind_for(t: &str) -> Option<(NodeKind, &'static str)> {
"text" => (NodeKind::Text(TextData::default()), "Text"),
"frame" => (NodeKind::Frame, "Frame"),
"section" => (NodeKind::Section { contents_hidden: false }, "Section"),
"component" => (NodeKind::Component { description: String::new(), props: Default::default() }, "Component"),
"component" => (NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() }, "Component"),
"group" => (NodeKind::Group, "Group"),
"slice" => (NodeKind::Slice, "Slice"),
_ => return None,
+6 -1
View File
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.duplicate",
"Duplicate",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place), map? (true: also return `map`, each original layer's id, descendants included, → its copy's id)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
true,
duplicate
),
@@ -633,6 +633,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
s.set_selection(out.clone());
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
if boolean(p, "map") == Some(true) {
// JSON object keys are strings: each original layer's id → its copy's.
let map: Map<String, Value> = map.into_iter().map(|(o, c)| (o.to_string(), json!(c))).collect();
return Ok(json!({"ids": out, "map": map}));
}
Ok(json!({"ids": out}))
}
+10 -4
View File
@@ -318,15 +318,21 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)),
spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)),
spec!("doc.list", "List documents", "{}", false, |s, _| {
Ok(Value::Array(
spec!(
"doc.list",
"List documents",
"{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`",
false,
|s, _| {
Ok(Value::Array(
s.docs
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.collect(),
))
}),
}
),
spec!("doc.home", "Home", "{}", false, |s, _| {
s.request_ui("home", json!({}));
ok()
+13
View File
@@ -70,11 +70,24 @@ pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_form
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig`
/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON.
/// Components containing each other's instances are broken apart, with a note.
pub(super) fn load_with(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, bool)> {
let (mut loaded, rest) = load_any(bytes, name_hint, variables, images)?;
let notes = crate::components::break_component_cycles(&mut loaded.doc);
loaded.issues.extend(notes);
Ok((loaded, rest))
}
fn load_any(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, bool)> {
match ext(name_hint).as_str() {
"svg" => {
@@ -31,7 +31,15 @@ fn cached_import() -> Document {
let page = doc.first_page().unwrap();
let store = doc.ensure_library_store();
let main = doc
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
.create(
store,
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
"Cached main",
0.0,
0.0,
20.0,
20.0,
)
.unwrap();
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
let inst = doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
@@ -66,7 +66,8 @@ fn invalid_flags_and_non_frames_keep_the_default_thumbnail() {
choose(&mut s, wide);
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle;
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails");
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Component { description: String::new(), props: Default::default() };
s.doc_mut().unwrap().get_mut(wide).unwrap().kind =
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() };
assert_ne!(thumbnail(s.doc().unwrap()).unwrap(), default, "a component counts as a frame");
}
+8 -6
View File
@@ -611,7 +611,7 @@ fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value)
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
"devStatus" => {
let st = if v.is_null() {
Some(DevStatus::None)
@@ -705,7 +705,7 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -
"contentAlign" | "counterAxisAlignContent" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
}
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
"gridColumns" | "gridRows" => {
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
@@ -714,8 +714,10 @@ fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -
*n = count;
tracks.truncate(count as usize);
}
"strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
"strokesIncluded" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?
}
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
_ => return Ok(false),
}
Ok(true)
@@ -823,7 +825,7 @@ fn apply_meta(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
"interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?,
"overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?,
"scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?,
"constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false),
"constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be a boolean")))?,
_ => return Ok(false),
}
Ok(true)
@@ -1004,7 +1006,7 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
"operandOperation" => set_operand_op(doc, id, v, cmd)?,
"sectionContentsHidden" => {
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind {
*contents_hidden = v.as_bool().unwrap_or(false);
*contents_hidden = v.as_bool().ok_or_else(|| bad(cmd, "`sectionContentsHidden` must be a boolean"))?;
}
}
"background" => {
+46 -14
View File
@@ -587,11 +587,14 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": out}))
}
/// Infer direction, gap and padding from where children are (Figma's "Add auto layout").
/// Infer direction, gap and padding from visible, non-absolute children, matching the layout flow.
fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
let Some(f) = doc.node(frame) else { return AutoLayout::default() };
let boxes: Vec<Rect> =
f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect();
let boxes: Vec<Rect> = figcraft_layout::flow_children(doc, frame)
.iter()
.filter_map(|c| doc.node(*c))
.map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()))
.collect();
if boxes.is_empty() {
return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() };
}
@@ -608,12 +611,43 @@ fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() }
}
/// Sort unmasked spans by position along the layout axis. Masks stay at their original indices;
/// each visible mask's following content keeps its paint order until the next mask, hidden or not.
fn sort_layout_children(doc: &Document, kids: &mut [NodeId], vertical: bool) {
let mut start = 0;
let mut masked = false;
for end in 0..=kids.len() {
let mask = kids.get(end).and_then(|id| doc.node(*id)).filter(|n| n.is_mask);
if end != kids.len() && mask.is_none() {
continue;
}
if !masked && let Some(span) = kids.get_mut(start..end) {
span.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
}
start = end + 1;
masked = mask.is_some_and(|n| n.visible);
}
}
/// Add inferred auto layout to existing frames or one group converted in place; wrap other
/// selections. Preserve mask boundaries and a converted group's Fill sizing in its parent.
fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("layout.add", s, p)?;
let dir = string(p, "direction");
let doc = s.doc()?;
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()));
let targets = if all_frames {
let single_group = ids.len() == 1 && ids.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Group));
let in_place = all_frames || single_group;
let targets = if in_place {
if single_group {
// Layout must arrange the group's children directly. Wrapping the group would
// leave them in a single fixed-size layout item and add an unnecessary layer.
convert(s, &json!({"ids": ids}), "layout.add", true)?;
}
ids
} else {
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
@@ -634,23 +668,21 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
_ => LayoutMode::Horizontal,
};
}
// Children in reading order along the axis.
let mut kids = doc.get(*f)?.children.clone();
let vertical = al.mode == LayoutMode::Vertical;
kids.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
sort_layout_children(doc, &mut kids, al.mode == LayoutMode::Vertical);
let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute));
let n = doc.get_mut(*f)?;
n.children = kids;
n.layout = Some(al);
if !all_frames || !empty {
if !in_place || !empty {
// A frame wrapped around the selection hugs it; an existing frame with no flow
// children keeps its size instead of collapsing to its padding.
n.child_layout.sizing_h = Sizing::Hug;
n.child_layout.sizing_v = Sizing::Hug;
if !single_group || n.child_layout.sizing_h != Sizing::Fill {
n.child_layout.sizing_h = Sizing::Hug;
}
if !single_group || n.child_layout.sizing_v != Sizing::Fill {
n.child_layout.sizing_v = Sizing::Hug;
}
}
}
s.set_selection(targets.clone());
+9 -2
View File
@@ -784,7 +784,7 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field: &str, vid: Option<&str>) -> Result<()> {
// An instance's component property takes a variable of its type; other fields the types their
// scopes belong to.
if let Some(v) = vid.and_then(|v| doc.variables.get(v)) {
if let Some(v) = vid.and_then(|v| doc.variables.get_by_id(v)) {
for i in ids {
match field.strip_prefix(crate::components::PROP_BINDING) {
Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?,
@@ -1253,7 +1253,14 @@ pub(crate) fn set_paint_color(list: &mut Vec<Paint>, i: usize, stop: Option<usiz
}
}
(Some(p), None) => p.kind = PaintKind::Solid { color: c },
(None, None) if i == 0 => list.push(Paint::solid(c)),
(None, None) if i == 0 => {
// A bound colour owns its alpha, so the new paint keeps it out of the opacity.
let mut p = Paint::solid(c);
p.kind = PaintKind::Solid { color: c };
p.opacity = 1.0;
p.color_alpha = 1.0;
list.push(p);
}
(None, _) => {}
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
/// `text` as a CSS comment or quoted string body.
fn css_safe(text: &str) -> String {
text.replace("*/", "* /").replace(['"', '\\', '\n'], " ")
text.replace("*/", "* /").replace(['"', '\\', '\n', '\r', '\u{c}'], " ")
}
/// Each collection's variables as custom properties (named as generated code names them): the
+13 -6
View File
@@ -394,12 +394,19 @@ pub fn specs() -> Vec<CommandSpec> {
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
Ok(json!({"small": s.prefs.nudge_small, "big": s.prefs.nudge_big}))
}),
spec!("pref.userName", "Your name in comments", "{name (blank: the default, \"You\")}", false, |s, p| {
let name = req_string("pref.userName", p, "name")?;
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(64).collect();
s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name };
Ok(json!({"name": s.prefs.user_name}))
}),
spec!(
"pref.userName",
"Your name",
"{name (blank: the default: \"You\" on comments, \"Guest N\" in live sessions)} → {name}: what your comments are signed with and others see you as in live sessions; the live sessions you're in show the new name at once (blank keeps the name they gave you until you join again)",
false,
|s, p| {
let name = req_string("pref.userName", p, "name")?;
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(crate::live::MAX_NAME).collect();
s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name };
s.live_rename();
Ok(json!({"name": s.prefs.user_name}))
}
),
spec!(
"pref.publishServer",
"Server for online sharing",
+78
View File
@@ -0,0 +1,78 @@
//! Comments from an AI agent (#1414): a command run as an agent (`Session::execute_as`, which the
//! control channel's and MCP's `engine.execute {agent}` use) signs comments with the agent's own
//! name and marks them as an agent's; the user's own comments stay as they were.
use serde_json::{Value, json};
use crate::Session;
use crate::cmd::comment::agent_author;
use crate::live::initials;
fn as_agent(s: &mut Session, agent: &str, cmd: &str, p: Value) -> Value {
s.execute_as(Some(agent), cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn thread(s: &mut Session, agent: Option<&str>) -> Value {
s.execute_as(agent, "comment.list", &json!({"filter": "all"})).unwrap()["comments"][0].clone()
}
#[test]
fn an_agents_comments_carry_its_name_and_the_agent_marker() {
let mut s = Session::new();
let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "@Claude make this bigger"})).unwrap()["id"].as_u64().unwrap();
as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "Done"}));
let t = thread(&mut s, None);
assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("You"), Some(false)), "the user's own comment is unchanged: {t}");
let r = &t["replies"][0];
assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Claude (agent)"), Some(true)), "{r}");
let doc = s.doc().unwrap();
let m = &doc.comment(c).unwrap().replies[0];
assert!(m.agent && m.author == "Claude (agent)");
// The agent acts as itself again later: no `author` on any call.
as_agent(&mut s, "Claude", "comment.react", json!({"id": c, "emoji": "👍"}));
assert_eq!(s.doc().unwrap().comment(c).unwrap().first.reactions[0].users, ["Claude (agent)"]);
assert_eq!(as_agent(&mut s, "Claude", "comment.people", json!({}))["you"], "Claude (agent)");
assert_eq!(s.execute("comment.people", &json!({})).unwrap()["you"], "You", "the user's name never changes");
assert_eq!(s.agent, None, "the agent acts only for its own call");
}
#[test]
fn an_agents_read_state_is_its_own() {
let mut s = Session::new();
let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Look"})).unwrap()["id"].as_u64().unwrap();
as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "On it"}));
assert_eq!(thread(&mut s, None)["unread"], true, "the agent's reply is unread for the user");
as_agent(&mut s, "Claude", "comment.markRead", json!({}));
assert_eq!(thread(&mut s, None)["unread"], true, "the agent reading it doesn't read it for the user");
assert_eq!(thread(&mut s, Some("Claude"))["unread"], false);
}
#[test]
fn an_agent_without_a_name_is_still_not_the_user() {
let mut s = Session::new();
as_agent(&mut s, "", "comment.add", json!({"x": 0, "y": 0, "text": "Hi"}));
let t = thread(&mut s, None);
assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("AI agent"), Some(true)), "{t}");
// An explicit `author` still wins, and the message is still an agent's.
as_agent(&mut s, "Claude", "comment.reply", json!({"id": t["id"], "text": "x", "author": "Helper"}));
let r = &thread(&mut s, None)["replies"][0];
assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Helper"), Some(true)), "{r}");
}
#[test]
fn agent_names_are_cleaned() {
assert_eq!(agent_author(" Claude "), "Claude (agent)");
assert_eq!(agent_author("\u{7}\n "), "AI agent");
assert_eq!(agent_author("a\u{0}b"), "ab (agent)");
let long = "x".repeat(500);
assert_eq!(agent_author(&long).chars().count(), 64 + " (agent)".len());
}
#[test]
fn an_agents_avatar_shows_its_names_initial() {
assert_eq!(initials("Claude (agent)"), "C");
assert_eq!(initials("AI agent"), "AA");
assert_eq!(initials("Ada Lovelace"), "AL");
assert_eq!(initials("(x) [y]"), "([", "no word starts with a letter: as before");
assert_eq!(initials(" "), "?");
}
@@ -0,0 +1,44 @@
//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights
//! them with: resolved against the file's people where it can be, and whether the name is you.
use serde_json::{Value, json};
use crate::Session;
fn mentions(s: &mut Session, p: Value) -> Vec<Value> {
let l = s.execute("comment.list", &p).unwrap();
let c = &l["comments"][0];
[&c["mentions"], &c["replies"][0]["mentions"]].into_iter().cloned().collect()
}
#[test]
fn a_message_lists_the_names_it_mentions_and_not_an_email_address() {
let mut s = Session::new();
let id = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Hi", "author": "Ada Lovelace"})).unwrap()["id"].as_u64().unwrap();
let text = "@ada lovelace and @You: please check, or ask @Bob (mail a@b.com). Thanks @Ada Lovelace";
s.execute("comment.reply", &json!({"id": id, "text": text, "author": "Sam"})).unwrap();
let [first, reply] = <[Value; 2]>::try_from(mentions(&mut s, json!({}))).unwrap();
assert_eq!(first, json!([]));
assert_eq!(
reply,
json!([
{"name": "Ada Lovelace", "known": true, "you": false},
{"name": "You", "known": true, "you": true},
{"name": "Bob", "known": false, "you": false},
]),
"two people the file knows (once each, as the file spells them), one name it doesn't, no email"
);
// `author` says whose list it is: then Ada is "you".
let reply = mentions(&mut s, json!({"author": "Ada Lovelace"})).remove(1);
assert_eq!(reply[0], json!({"name": "Ada Lovelace", "known": true, "you": true}));
}
#[test]
fn the_mention_rule_matches_the_highlighting_rule() {
let people = ["Sam", "Sam Lee"];
let found = figcraft_doc::mentions("@Sam Lee, @sam, x@Sam, @Zoe!", &people);
let names: Vec<_> = found.iter().map(|(r, who)| (r.clone(), *who)).collect();
assert_eq!(names, vec![(0..8, Some("Sam Lee")), (10..14, Some("Sam")), (23..27, None)]);
assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]);
assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty());
}
+234
View File
@@ -0,0 +1,234 @@
//! Components can't contain their own instances, directly, through other components or through
//! the variants of a set (#1084); files holding such a loop open with it broken.
use std::sync::Arc;
use base64::Engine;
use figcraft_doc::{Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::components::component_cycle;
use crate::components::tests::{copy_of, ex, idv, kids, node};
/// A 100×50 component named `name` at (x, 0), holding a rectangle; returns its id.
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 50, "name": name})));
ex(s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}));
idv(&ex(s, "component.create", json!({"ids": [f]})))
}
fn instance(s: &mut Session, component: NodeId, parent: NodeId) -> NodeId {
idv(&ex(s, "instance.create", json!({"component": component, "x": 20, "y": 20, "parent": parent})))
}
/// `cmd` is refused with the loop explained, and the document is left as it was.
fn refused(s: &mut Session, cmd: &str, p: Value) {
let before = s.doc().unwrap().clone();
let undo = s.ds().unwrap().history.undo.len();
let err = s.execute(cmd, &p).expect_err(&format!("{cmd} {p} should be refused")).to_string();
assert!(err.contains("can't contain"), "{cmd}: {err}");
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "{cmd} left no undo step");
}
/// Card holds a Badge instance; Badge holds an Icon instance. Returns (card, badge, icon, the
/// Icon instance in Badge).
fn chain(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let icon = component(s, "Icon", 0.0);
let badge = component(s, "Badge", 200.0);
let card = component(s, "Card", 400.0);
let in_badge = instance(s, icon, badge);
instance(s, badge, card);
(card, badge, icon, in_badge)
}
#[test]
fn direct_and_indirect_loops_are_refused() {
let mut s = Session::new();
let (card, badge, _, in_badge) = chain(&mut s);
// Directly, and through Badge: Card already shows Badge.
refused(&mut s, "instance.create", json!({"component": card, "parent": card}));
refused(&mut s, "instance.create", json!({"component": card, "parent": badge}));
refused(&mut s, "instance.swap", json!({"ids": [in_badge], "component": card}));
// A Card instance moved or pasted into Badge.
let page = s.page().unwrap();
let loose = instance(&mut s, card, page);
refused(&mut s, "node.reparent", json!({"ids": [loose], "parent": badge}));
ex(&mut s, "edit.copy", json!({"ids": [loose]}));
refused(&mut s, "edit.paste", json!({"parent": badge}));
// A copied main pastes as an instance of it.
ex(&mut s, "edit.copy", json!({"ids": [card]}));
refused(&mut s, "edit.paste", json!({"parent": badge}));
assert!(component_cycle(s.doc().unwrap()).is_none());
}
#[test]
fn dropping_an_instance_where_it_would_contain_itself_places_it_on_the_page() {
let mut s = Session::new();
let (card, badge, _, _) = chain(&mut s);
let r = ex(&mut s, "instance.place", json!({"component": card, "x": 250, "y": 25}));
assert_eq!(r["parent"], json!(s.page().unwrap()), "not into Badge: {r}");
assert!(!s.doc().unwrap().is_ancestor(badge, idv(&r)));
}
#[test]
fn an_instance_swap_property_can_not_close_a_loop() {
let mut s = Session::new();
let (card, badge, icon, in_badge) = chain(&mut s);
ex(&mut s, "component.addProperty", json!({"id": badge, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": in_badge}));
let in_card = kids(&s, card).into_iter().find(|k| matches!(node(&s, *k).kind, NodeKind::Instance { .. })).unwrap();
refused(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": card}}));
// Another component is fine.
let star = component(&mut s, "Star", 600.0);
ex(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": star}}));
}
/// A set of two variants, "State=On" holding an instance of "State=Off". Returns (on, off, the Off
/// instance in On).
fn nested_variants(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let on = component(s, "State=On", 0.0);
let off = component(s, "State=Off", 200.0);
ex(s, "component.combineVariants", json!({"ids": [on, off]}));
let held = instance(s, off, on);
(on, off, held)
}
#[test]
fn a_variant_holds_a_sibling_but_no_loop_back_through_it() {
let mut s = Session::new();
let (on, off, held) = nested_variants(&mut s);
assert!(component_cycle(s.doc().unwrap()).is_none(), "a sibling variant's instance is no loop");
// Off leading back to On, directly or through another component, is one.
refused(&mut s, "instance.create", json!({"component": on, "parent": off}));
let row = component(&mut s, "Row", 600.0);
instance(&mut s, on, row);
refused(&mut s, "instance.create", json!({"component": row, "parent": off}));
// So is switching the held instance to the variant holding it.
refused(&mut s, "instance.setProperties", json!({"ids": [held], "properties": {"State": "On"}}));
// Combining a component with one holding its instance is fine too.
let a = component(&mut s, "A", 0.0);
let b = component(&mut s, "B", 200.0);
instance(&mut s, a, b);
ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}));
assert!(component_cycle(s.doc().unwrap()).is_none());
}
/// `doc` saved and opened again: the session and what opening it reported.
fn reopened(doc: &Document) -> (Session, Value) {
let data = base64::engine::general_purpose::STANDARD.encode(figcraft_format::save_bytes(doc, None).unwrap());
let mut s = Session::new();
let r = ex(&mut s, "file.open", json!({"data": data, "name": "variants.figcraft"}));
(s, r)
}
#[test]
fn a_file_with_nested_sibling_variants_opens_unchanged_and_a_loop_through_them_is_repaired() {
let mut s = Session::new();
let (on, off, held) = nested_variants(&mut s);
let doc = s.doc().unwrap().clone();
let (t, r) = reopened(&doc);
assert_eq!(r["repairs"], json!([]), "{r}");
assert_eq!(t.doc().unwrap().nodes().count(), doc.nodes().count());
assert!(matches!(node(&t, held).kind, NodeKind::Instance { component, .. } if component == off));
// On → Off → On: an On instance in Off, written directly as an older file could have it.
let spare = component(&mut s, "Spare", 600.0);
let back = instance(&mut s, spare, off);
let mut doc = s.doc().unwrap().clone();
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(back).unwrap().kind {
*component = on;
}
assert!(component_cycle(&doc).is_some());
let (t, r) = reopened(&doc);
assert!(r["repairs"].as_array().is_some_and(|n| n.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached")))), "{r}");
assert!(component_cycle(t.doc().unwrap()).is_none());
}
#[test]
fn components_around_instances_and_instances_in_slots_stay_allowed() {
let mut s = Session::new();
let (card, badge, ..) = chain(&mut s);
// A new component around a Card instance.
let page = s.page().unwrap();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100, "parent": page})));
instance(&mut s, card, f);
let holder = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
instance(&mut s, badge, holder);
// An instance in a slot of an instance of its own component: the slot holds that instance's
// own content, outside its main.
let slot = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": card, "name": "Slot"})));
ex(&mut s, "component.makeSlot", json!({"id": slot}));
let outer = instance(&mut s, card, page);
let outer_slot = copy_of(&s, outer, slot);
instance(&mut s, card, outer_slot);
assert!(component_cycle(s.doc().unwrap()).is_none());
}
/// A document where Card holds a Badge instance and Badge a Card instance, made by editing the
/// layers directly (as an older file or a merge could have it). Returns it with (card, badge).
fn looped() -> (Document, NodeId, NodeId) {
let mut s = Session::new();
let (card, badge, _, in_badge) = chain(&mut s);
let mut doc = s.doc().unwrap().clone();
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(in_badge).unwrap().kind {
*component = card;
}
crate::components::sync_instance(&mut doc, in_badge);
assert!(component_cycle(&doc).is_some());
(doc, card, badge)
}
/// Edit mains `a` and `b` `n` times, every instance rebuilt in full each time; the layer count
/// after each round.
fn edit_mains(s: &mut Session, a: NodeId, b: NodeId, n: usize) -> Vec<usize> {
crate::components::FULL_REBUILD.with(|f| f.set(true));
let sizes = (0..n)
.map(|i| {
ex(s, "node.set", json!({"id": a, "props": {"fill": format!("#{:02x}0000", i * 20)}}));
ex(s, "node.set", json!({"id": b, "props": {"fill": format!("#00{:02x}00", i * 20)}}));
s.doc().unwrap().nodes().count()
})
.collect();
crate::components::FULL_REBUILD.with(|f| f.set(false));
sizes
}
#[test]
fn a_file_with_a_loop_opens_with_it_broken_and_a_note() {
let (doc, card, badge) = looped();
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
let mut s = Session::new();
let r = ex(&mut s, "file.open", json!({"data": data, "name": "looped.figcraft"}));
let notes = r["repairs"].as_array().cloned().unwrap_or_default();
assert!(notes.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached"))), "{r}");
let doc = s.doc().unwrap();
assert!(component_cycle(doc).is_none());
// The detached instance kept its layers; the other side is still an instance.
assert!(doc.contains(card) && doc.contains(badge));
let count = doc.nodes().count();
// Editing the mains no longer grows the file.
assert_eq!(edit_mains(&mut s, card, badge, 10), vec![count; 10]);
// Importing it into another file breaks it the same way.
let mut t = Session::new();
let r = ex(&mut t, "file.import", json!({"data": data, "name": "looped.figcraft"}));
assert!(r["warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
assert!(component_cycle(t.doc().unwrap()).is_none());
}
#[test]
fn a_loop_merged_in_stops_expanding_and_the_document_stays_editable() {
let (doc, card, badge) = looped();
let mut s = Session::new();
let uid = s.ds().unwrap().uid;
s.apply_remote(uid, Arc::new(doc));
assert!(s.message.as_deref().is_some_and(|m| m.contains("contain each other")), "{:?}", s.message);
let sizes = edit_mains(&mut s, card, badge, 12);
// A couple of rebuilds settle the copies; after that nothing grows.
assert!(sizes.windows(2).skip(2).all(|w| w[0] == w[1]), "{sizes:?}");
// Editing goes on: layers added to a main reach its instances.
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": card}));
let page = s.page().unwrap();
let loose = instance(&mut s, badge, page);
assert!(!kids(&s, loose).is_empty());
}
+9 -2
View File
@@ -82,8 +82,15 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
let page = doc.first_page().ok_or(figcraft_doc::DocError::Invalid("new document has no page".into()))?;
let layers = layers.clamp(10, 2_000_000);
// The shared button component (3 layers), above the screens.
let button =
doc.create(page, NodeKind::Component { description: String::new(), props: Default::default() }, "Button", 0.0, -120.0, 96.0, 32.0)?;
let button = doc.create(
page,
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
"Button",
0.0,
-120.0,
96.0,
32.0,
)?;
let bg = doc.create(button, NodeKind::Rectangle, "Background", 0.0, 0.0, 96.0, 32.0)?;
fill(&mut doc, bg, Color::from_rgb8_const(0x0D, 0x99, 0xFF));
if let Some(n) = doc.node_mut(bg) {
+1 -1
View File
@@ -118,7 +118,7 @@ fn var_id(name: &str) -> String {
}
fn component() -> NodeKind {
NodeKind::Component { description: String::new(), props: BTreeMap::new() }
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }
}
fn text(chars: &str) -> NodeKind {

Some files were not shown because too many files have changed in this diff Show More