mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge main into #1420
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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(|_| ())
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -95,3 +95,6 @@ sync_github_name = true
|
||||
|
||||
[people.StrajnarFilip]
|
||||
display_name = "Filip Strajnar"
|
||||
|
||||
[people.Carbocode]
|
||||
sync_github_name = true
|
||||
|
||||
+136
-21
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())? {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod board;
|
||||
pub mod board_grid;
|
||||
pub mod board_table;
|
||||
pub mod board_templates;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user