mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 06:16:46 -06:00
M9: share with a join code: online through the built-in server or on the local network
The Share popover is one flow: Online (the file is published to the server in
Preferences and the server names its links with short codes) or Local network
(the app hosts; the code carries its address and the session's secret), one
Start sharing button, the code large with Copy code and Copy link, who is here,
can edit / can view, and Stop sharing. Join with a code is one field in the
popover, on the home screen and in the Join dialog, for a code of either kind
or an invite link.
Protocol (within version 2, new variants last): Resolve / Resolved and Admin
code. The server keeps a stretched hash and the wrapped token, never the code,
and limits wrong codes per address (with a lockout) and for everyone. Older
servers leave the links in place of a code; older apps join by link.
collab.share, collab.join {code}, code and share in collab.peers; the server
preference defaults to the built-in server and can be reset or turned off.
This commit is contained in:
@@ -229,8 +229,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
|
||||
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
|
||||
|
||||
|
||||
+7
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -131,6 +131,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or
|
||||
**Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that
|
||||
carries the host's address); others type it under Join with a code (the popover, the home screen), where a link
|
||||
works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`,
|
||||
`collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links)
|
||||
- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and
|
||||
compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing
|
||||
goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before
|
||||
@@ -271,6 +276,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the file leaves the server); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged |
|
||||
| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
|
||||
|
||||
@@ -5,10 +5,16 @@
|
||||
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
|
||||
//! [`MAX_ADDRESSES`] addresses: when it is full the half used longest ago is forgotten, so a flood
|
||||
//! of addresses costs bounded memory (and forgotten addresses start with full buckets).
|
||||
//!
|
||||
//! Join codes are short, so guessing them is held back three ways: an address may try
|
||||
//! [`CODES_PER_MINUTE`] codes a minute, [`WRONG_CODES`] wrong ones in a row lock it out of codes for
|
||||
//! [`CODE_LOCKOUT`] (links still join), and everyone together may get [`WRONG_CODES_PER_HOUR`]
|
||||
//! wrong an hour (an attacker with many addresses is slowed to that; while it lasts, people join
|
||||
//! with their links).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab_net::hub::{Block, ip_blocks};
|
||||
use figcraft_collab_net::net::Bucket;
|
||||
@@ -28,6 +34,15 @@ 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;
|
||||
|
||||
/// 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.
|
||||
pub const WRONG_CODES: u32 = 10;
|
||||
/// How long an address is locked out of join codes.
|
||||
pub const CODE_LOCKOUT: Duration = Duration::from_secs(15 * 60);
|
||||
/// Wrong codes everyone together may try an hour.
|
||||
pub const WRONG_CODES_PER_HOUR: f64 = 1_000.0;
|
||||
|
||||
/// The rates (see the server's settings).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AbuseConfig {
|
||||
@@ -49,6 +64,8 @@ pub enum Action {
|
||||
Report,
|
||||
/// Downloading this many bytes.
|
||||
Download(usize),
|
||||
/// Trying a join code.
|
||||
Code,
|
||||
}
|
||||
|
||||
struct Address {
|
||||
@@ -58,6 +75,12 @@ struct Address {
|
||||
presence: Bucket,
|
||||
reports: Bucket,
|
||||
downloads: Bucket,
|
||||
codes: Bucket,
|
||||
/// Wrong join codes in a row, and when the last one was.
|
||||
wrong: u32,
|
||||
wrong_at: Instant,
|
||||
/// Locked out of join codes until then.
|
||||
locked: Option<Instant>,
|
||||
used: Instant,
|
||||
}
|
||||
|
||||
@@ -67,6 +90,8 @@ pub struct Abuse {
|
||||
table: HashMap<Block, Address>,
|
||||
/// Publishing without the key, everyone together.
|
||||
publishes: Bucket,
|
||||
/// Wrong join codes, everyone together.
|
||||
wrong_codes: Bucket,
|
||||
}
|
||||
|
||||
/// `n` every `period` seconds on average, and all `n` at once at most.
|
||||
@@ -77,7 +102,44 @@ fn bucket(n: f64, period: f64) -> Bucket {
|
||||
impl Abuse {
|
||||
pub fn new(cfg: AbuseConfig) -> Self {
|
||||
let publishes = bucket(f64::from(cfg.publishes_per_hour) * PUBLISHES_ALL, 3600.0);
|
||||
Self { cfg, table: HashMap::new(), publishes }
|
||||
Self { cfg, table: HashMap::new(), publishes, wrong_codes: bucket(WRONG_CODES_PER_HOUR, 3600.0) }
|
||||
}
|
||||
|
||||
/// How long `ip` is still locked out of join codes (in any block it is in), if it is.
|
||||
pub fn code_locked(&self, ip: IpAddr, now: Instant) -> Option<Duration> {
|
||||
let until = ip_blocks(ip).into_iter().filter_map(|(k, _)| self.table.get(&k)?.locked).max()?;
|
||||
Some(until.saturating_duration_since(now)).filter(|d| !d.is_zero())
|
||||
}
|
||||
|
||||
/// Everyone together has wrong codes left to try (see [`WRONG_CODES_PER_HOUR`]).
|
||||
pub fn codes_open(&mut self, now: Instant) -> bool {
|
||||
let open = self.wrong_codes.take(now);
|
||||
if open {
|
||||
self.wrong_codes.give(1.0);
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
|
||||
/// before it; enough wrong ones in a row lock the address out.
|
||||
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
|
||||
if !ok {
|
||||
self.wrong_codes.take(now);
|
||||
}
|
||||
for (k, times) in ip_blocks(ip) {
|
||||
let a = self.address(k, f64::from(times), now);
|
||||
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
|
||||
a.wrong = 0;
|
||||
}
|
||||
if !ok {
|
||||
a.wrong += 1;
|
||||
a.wrong_at = now;
|
||||
if a.wrong >= WRONG_CODES.saturating_mul(times) {
|
||||
a.wrong = 0;
|
||||
a.locked = Some(now + CODE_LOCKOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `ip` may do `what` now (counting it, in every block it is in, when it may).
|
||||
@@ -114,6 +176,10 @@ impl Abuse {
|
||||
presence: Bucket::new(f64::from(c.messages_per_second) * times * 2.0, f64::from(c.messages_per_second) * times * 8.0),
|
||||
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),
|
||||
wrong: 0,
|
||||
wrong_at: now,
|
||||
locked: None,
|
||||
used: now,
|
||||
});
|
||||
a.used = now;
|
||||
@@ -128,6 +194,7 @@ impl Abuse {
|
||||
Action::Presence => (&mut a.presence, 1.0),
|
||||
Action::Report => (&mut a.reports, 1.0),
|
||||
Action::Download(n) => (&mut a.downloads, n as f64),
|
||||
Action::Code => (&mut a.codes, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ fn key(cfg: &Config, rotate: bool) -> Result<String, String> {
|
||||
if rotate {
|
||||
out.push_str("A new server key: the old one no longer works.\n");
|
||||
}
|
||||
let _ = writeln!(out, "Publish to this address (in FigCraft: Share > Publish to server…):");
|
||||
let _ = writeln!(out, "Publish to this address (in FigCraft: File > Publish to server…):");
|
||||
let _ = writeln!(out, " {}?key={key}", public.url());
|
||||
let _ = writeln!(out, "Whoever has it can publish files and manage their links: share it only with people you trust.");
|
||||
if cfg.open_publish {
|
||||
@@ -375,7 +375,7 @@ fn used(f: &FileRow) -> u64 {
|
||||
|
||||
fn files_table(mut files: Vec<FileRow>, sort: Sort, now: u64) -> String {
|
||||
if files.is_empty() {
|
||||
return "No files yet (publish one from FigCraft: Share > Publish to server…).\n".into();
|
||||
return "No files yet (publish one from FigCraft: File > Publish to server…).\n".into();
|
||||
}
|
||||
match sort {
|
||||
Sort::Id => {}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Join codes on the server: a short name for one share link, typed in place of the link.
|
||||
//!
|
||||
//! A code is 45 random bits ([`figcraft_collab::code`]). Like a link's token it is never stored:
|
||||
//! a link keeps the code's hash, to find the link by, and its own token wrapped with the code, to
|
||||
//! hand to whoever types the code (they then join as with the link). Both come from the code
|
||||
//! stretched with SHA-256 ([`STRETCH`] rounds), so a copy of the data directory doesn't give the
|
||||
//! codes away cheaply; guessing against a running server is held back by the limits on wrong codes
|
||||
//! (`abuse.rs`).
|
||||
|
||||
use figcraft_collab::{code, same_secret};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Rounds of SHA-256 a code goes through before it is compared or used as a key.
|
||||
pub const STRETCH: u32 = 10_000;
|
||||
|
||||
// A copy of the data directory mustn't give codes away cheaply.
|
||||
const _: () = assert!(STRETCH >= 10_000);
|
||||
|
||||
/// Longest token a code can wrap (one SHA-256 output).
|
||||
const MAX_TOKEN: usize = 32;
|
||||
|
||||
/// What a link keeps of its join code (`links.json`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Sealed {
|
||||
/// The stretched code's hash, hex: what a typed code is looked up by.
|
||||
pub hash: String,
|
||||
/// The link's token, wrapped with the stretched code (hex).
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// A typed code, stretched: ready to look links up with (what it is found by, and the pad its
|
||||
/// link's token is wrapped with).
|
||||
pub struct Key {
|
||||
hash: String,
|
||||
pad: [u8; 32],
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// Stretch `code` (its symbols, as [`code::normalize`] gives them).
|
||||
pub fn new(code: &str) -> Key {
|
||||
let mut k: [u8; 32] = Sha256::new().chain_update(b"figcraft join code\0").chain_update(code.as_bytes()).finalize().into();
|
||||
for _ in 0..STRETCH {
|
||||
k = Sha256::digest(k).into();
|
||||
}
|
||||
let part = |what: &[u8]| -> [u8; 32] { Sha256::new().chain_update(what).chain_update(k).finalize().into() };
|
||||
Key { hash: hex(&part(b"find\0")), pad: part(b"wrap\0") }
|
||||
}
|
||||
|
||||
/// What a link with this code keeps as [`Sealed::hash`].
|
||||
pub fn hash(&self) -> &str {
|
||||
&self.hash
|
||||
}
|
||||
|
||||
/// Wrap or unwrap `bytes` (the same operation: XOR with a pad only this code gives).
|
||||
fn wrap(&self, bytes: &[u8]) -> Vec<u8> {
|
||||
bytes.iter().zip(self.pad).map(|(b, p)| b ^ p).collect()
|
||||
}
|
||||
|
||||
/// Keep `token` under this code.
|
||||
pub fn seal(&self, token: &str) -> Result<Sealed, String> {
|
||||
if token.len() > MAX_TOKEN {
|
||||
return Err("this link's token is too long for a join code".into());
|
||||
}
|
||||
Ok(Sealed { hash: self.hash.clone(), token: hex(&self.wrap(token.as_bytes())) })
|
||||
}
|
||||
|
||||
/// The token `sealed` keeps, when this is its code (compared in constant time).
|
||||
pub fn open(&self, sealed: &Sealed) -> Option<String> {
|
||||
if !same_secret(&self.hash, &sealed.hash) || sealed.token.len() > 2 * MAX_TOKEN {
|
||||
return None;
|
||||
}
|
||||
let wrapped: Option<Vec<u8>> =
|
||||
(0..sealed.token.len() / 2).map(|i| sealed.token.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
|
||||
String::from_utf8(self.wrap(&wrapped?)).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// A new code's symbols, from the operating system's random numbers.
|
||||
pub fn new_code() -> Result<String, String> {
|
||||
figcraft_collab_net::token::random().map(code::online)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Join codes on the server: sharing online from one app and joining by code from another (real
|
||||
//! links over loopback), codes kept only hashed, revoked with their link and gone with their file,
|
||||
//! the limits on wrong codes, junk requests, and clients from before join codes.
|
||||
|
||||
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};
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action, CODE_LOCKOUT, CODES_PER_MINUTE, WRONG_CODES, WRONG_CODES_PER_HOUR};
|
||||
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};
|
||||
|
||||
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}"))
|
||||
}
|
||||
|
||||
/// An app whose server for online sharing is the one at `url`.
|
||||
fn app(name: &str, url: &str) -> Session {
|
||||
let mut s = session(name);
|
||||
ex(&mut s, "pref.publishServer", json!({"url": url}));
|
||||
s
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().map_or(0, |d| d.node_count())
|
||||
}
|
||||
|
||||
fn failed(s: &Session) -> Option<String> {
|
||||
match &s.live().status {
|
||||
Status::Failed { reason } => Some(reason.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_code(shown: &str) -> bool {
|
||||
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let dir = TempDir::new("codes-share");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = app("Ada", &url);
|
||||
// Sharing: the file goes to the server in Preferences, and the server names its links.
|
||||
let started = ex(&mut a, "collab.share", json!({}));
|
||||
assert_eq!((&started["status"], &started["share"]["via"], &started["code"]), (&json!("connecting"), &json!("online"), &Value::Null));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let shared = a.live_summary();
|
||||
let (edit, view) = (shared["share"]["editCode"].as_str().unwrap().to_string(), shared["share"]["viewCode"].as_str().unwrap().to_string());
|
||||
assert!(is_code(&edit) && is_code(&view) && edit != view, "{edit} {view}");
|
||||
assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise");
|
||||
let p = a.live().published.clone().unwrap();
|
||||
assert!(p.owner.is_some(), "the owner token is kept as before");
|
||||
// On disk: hashes only, of the codes as of the links' tokens.
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
|
||||
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
// Someone types the edit code, the way people type.
|
||||
let mut b = app("Bo", &url);
|
||||
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
assert_eq!(b.live_summary()["access"], "edit");
|
||||
assert_eq!(b.live_summary()["link"], p.edit.as_str(), "joined as with the link: reconnects need no code");
|
||||
let before = layers(&a);
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
|
||||
pump(&mut [&mut a, &mut b], "Ada's edit", |s| layers(s[1]) == before + 1);
|
||||
ex(&mut b, "shape.ellipse", json!({"x": 80, "y": 10, "width": 40, "height": 30}));
|
||||
pump(&mut [&mut a, &mut b], "Bo's edit", |s| layers(s[0]) == before + 2);
|
||||
// The view code lets someone look, not edit.
|
||||
assert_eq!(ex(&mut a, "collab.share", json!({"access": "view"}))["code"], view.as_str());
|
||||
let mut c = app("Cy", &url);
|
||||
ex(&mut c, "collab.join", json!({"code": view}));
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy in", |s| live(s[2]) && layers(s[2]) == before + 2);
|
||||
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("view"), &json!(true)));
|
||||
assert!(c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
|
||||
// A code nobody was given.
|
||||
let mut d = app("Di", &url);
|
||||
ex(&mut d, "collab.join", json!({"code": "000-000-000"}));
|
||||
pump(&mut [&mut d], "the refusal", |s| failed(s[0]).is_some());
|
||||
assert_eq!(failed(&d).unwrap(), NO_CODE);
|
||||
// Stop sharing: the file leaves the server, everyone is told, and the code no longer joins.
|
||||
assert_eq!(ex(&mut a, "collab.stop", json!({}))["ended"], true);
|
||||
let folder = dir.path().join("files").join(&p.file);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "the file gone", |s| !folder.exists() && failed(s[1]).is_some() && failed(s[2]).is_some());
|
||||
assert!(failed(&b).unwrap().contains("removed"), "{:?}", failed(&b));
|
||||
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
|
||||
let mut e = app("Ed", &url);
|
||||
ex(&mut e, "collab.join", json!({"code": edit}));
|
||||
pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some());
|
||||
assert_eq!(failed(&e).unwrap(), CODE_GONE);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyed_share_gets_codes_too_and_leaving_keeps_the_file() {
|
||||
let dir = TempDir::new("codes-keyed");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.share", json!({"server": format!("{url}?key={}", crate::test_util::key(dir.path())), "name": "Team file"}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let shown = a.live_summary()["code"].as_str().unwrap().to_string();
|
||||
assert!(is_code(&shown));
|
||||
assert!(!a.live_summary().to_string().contains("key="), "the key is nowhere in the summary");
|
||||
// No owner token: stopping leaves the session, the operator's file stays, and so does its code.
|
||||
ex(&mut a, "collab.stop", json!({}));
|
||||
let mut b = app("Bo", &url);
|
||||
ex(&mut b, "collab.join", json!({"code": shown}));
|
||||
pump(&mut [&mut b], "Bo in", |s| live(s[0]));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_are_hashed_replaced_revoked_and_gone_with_the_file() {
|
||||
let dir = TempDir::new("codes-store");
|
||||
let cfg = open(dir.path());
|
||||
let me = ip("192.0.2.7");
|
||||
let (file, edit_token, view_link, shown, view_code) = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, edit, view, _owner) = store.publish_open(me, "Poster", &snapshot("Poster")).unwrap();
|
||||
let shown = store.set_code(&file.id, &edit.token).unwrap();
|
||||
let view_code = store.set_code(&file.id, &view.token).unwrap();
|
||||
assert_ne!(shown, view_code, "a code per link");
|
||||
assert_eq!((shown.len(), code::normalize(&shown).as_deref()), (code::ONLINE_LEN, Ok(shown.as_str())));
|
||||
// The code gives the link's token back, and the token admits with the link's access.
|
||||
assert_eq!(store.resolve_code(me, &shown).unwrap(), (file.id.clone(), edit.token.clone()));
|
||||
let (f, t) = store.resolve_code(me, &code::grouped(&view_code).to_lowercase()).unwrap();
|
||||
assert_eq!(store.admit(&f, &t).unwrap().access, figcraft_collab::Access::View);
|
||||
// A token that isn't one of the file's links gets no code.
|
||||
assert!(store.set_code(&file.id, "AAAAAAAAAAAAAAAAAAAAAA").unwrap_err().contains("isn't one of this file's links"));
|
||||
assert!(store.set_code("0000000000000000", &edit.token).is_err());
|
||||
(file.id, edit.token, view.link.id, shown, view_code)
|
||||
};
|
||||
// What is kept: the stretched code's hash and the wrapped token, nothing that is either.
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&file).join("links.json")).unwrap();
|
||||
assert!(!links.contains(&shown) && !links.contains(&edit_token), "{links}");
|
||||
assert!(links.contains(Key::new(&shown).hash()));
|
||||
// A restart keeps the codes (they are in the data directory).
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
assert_eq!(store.resolve_code(me, &shown).unwrap().1, edit_token);
|
||||
// A new code for the link takes the old one's place.
|
||||
let newer = store.set_code(&file, &edit_token).unwrap();
|
||||
assert_ne!(newer, shown);
|
||||
assert_eq!(store.resolve_code(me, &shown).unwrap_err(), CODE_GONE);
|
||||
assert_eq!(store.resolve_code(me, &newer).unwrap().1, edit_token);
|
||||
// Revoking a link revokes its code.
|
||||
store.revoke(&file, &view_link).unwrap();
|
||||
assert_eq!(store.resolve_code(me, &view_code).unwrap_err(), CODE_GONE);
|
||||
// The file expiring takes the rest.
|
||||
store.age(&file, 31 * 86_400);
|
||||
assert_eq!(store.sweep(), vec![file.clone()]);
|
||||
assert_eq!(store.resolve_code(me, &newer).unwrap_err(), CODE_GONE);
|
||||
// A code never handed out is unknown, not expired.
|
||||
assert_eq!(store.resolve_code(ip("192.0.2.8"), "ZZZZZZZZZ").unwrap_err(), NO_CODE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_codes_lock_an_address_out_but_not_the_others() {
|
||||
let dir = TempDir::new("codes-lockout");
|
||||
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
|
||||
let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap();
|
||||
let shown = store.set_code(&file.id, &edit.token).unwrap();
|
||||
let guesser = ip("198.51.100.9");
|
||||
for n in 0..WRONG_CODES {
|
||||
assert_eq!(store.resolve_code(guesser, &code::online(u64::from(n) + 1)).unwrap_err(), NO_CODE, "guess {n}");
|
||||
}
|
||||
// Locked out: not even the right code is looked up for that address.
|
||||
for typed in [code::online(77), shown.clone()] {
|
||||
let e = store.resolve_code(guesser, &typed).unwrap_err();
|
||||
assert!(e.contains("Too many wrong codes") && e.contains("15 minutes") && e.contains("invite link"), "{e}");
|
||||
}
|
||||
// Its link still joins, and other addresses' codes still work.
|
||||
assert!(store.admit(&file.id, &edit.token).is_ok());
|
||||
assert_eq!(store.resolve_code(ip("198.51.100.10"), &shown).unwrap().0, file.id);
|
||||
// An IPv6 guesser is counted by its /64.
|
||||
for n in 0..WRONG_CODES {
|
||||
let from: IpAddr = format!("2001:db8:1:2::{n:x}").parse().unwrap();
|
||||
let _ = store.resolve_code(figcraft_collab_net::hub::ip_key(from), &code::online(1000 + u64::from(n)));
|
||||
}
|
||||
let neighbour = figcraft_collab_net::hub::ip_key(ip("2001:db8:1:2:ffff::1"));
|
||||
assert!(store.resolve_code(neighbour, &shown).unwrap_err().contains("Too many wrong codes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limits_on_codes_over_time() {
|
||||
let t0 = Instant::now();
|
||||
let me = ip("198.51.100.9");
|
||||
let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
|
||||
// A right code forgives the wrong ones before it.
|
||||
for _ in 0..WRONG_CODES - 1 {
|
||||
abuse.code_tried(me, false, t0);
|
||||
}
|
||||
abuse.code_tried(me, true, t0);
|
||||
for _ in 0..WRONG_CODES - 1 {
|
||||
abuse.code_tried(me, false, t0);
|
||||
}
|
||||
assert_eq!(abuse.code_locked(me, t0), None);
|
||||
// One more: locked for the lockout, then free again.
|
||||
abuse.code_tried(me, false, t0);
|
||||
assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT));
|
||||
assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some());
|
||||
assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None);
|
||||
// Wrong codes long apart don't add up.
|
||||
let other = ip("198.51.100.10");
|
||||
for n in 0..3 * WRONG_CODES {
|
||||
abuse.code_tried(other, false, t0 + (CODE_LOCKOUT + Duration::from_secs(1)) * n);
|
||||
}
|
||||
assert_eq!(abuse.code_locked(other, t0 + CODE_LOCKOUT * 4 * WRONG_CODES), None);
|
||||
// Codes tried a minute, right or wrong (these limits hold on private servers too).
|
||||
let typist = ip("198.51.100.11");
|
||||
let tried = (0..100).filter(|_| abuse.allow(typist, Action::Code, t0)).count();
|
||||
assert_eq!(tried as f64, CODES_PER_MINUTE);
|
||||
assert!(abuse.allow(typist, Action::Code, t0 + Duration::from_secs(7)), "one more every six seconds");
|
||||
// Everyone together: many addresses guessing pause codes for all (links still join).
|
||||
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
|
||||
assert!(abuse.codes_open(t0));
|
||||
for n in 0..WRONG_CODES_PER_HOUR as u32 {
|
||||
abuse.code_tried(IpAddr::from([10, 1, (n >> 8) as u8, n as u8]), false, t0);
|
||||
}
|
||||
assert!(!abuse.codes_open(t0));
|
||||
assert!(abuse.codes_open(t0 + Duration::from_secs(60)), "it refills");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_guessing_pauses_codes() {
|
||||
let dir = TempDir::new("codes-busy");
|
||||
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
|
||||
let (file, edit, _, _) = store.publish_open(ip("192.0.2.7"), "Poster", &snapshot("Poster")).unwrap();
|
||||
let shown = store.set_code(&file.id, &edit.token).unwrap();
|
||||
// Junk costs no hashing, so a thousand wrong "codes" from a thousand addresses are quick.
|
||||
for n in 0..WRONG_CODES_PER_HOUR as u32 {
|
||||
assert_eq!(store.resolve_code(IpAddr::from([10, 2, (n >> 8) as u8, n as u8]), "not a code").unwrap_err(), NO_CODE);
|
||||
}
|
||||
assert_eq!(store.resolve_code(ip("192.0.2.99"), &shown).unwrap_err(), CODES_BUSY);
|
||||
assert!(store.admit(&file.id, &edit.token).is_ok(), "links are not affected");
|
||||
}
|
||||
|
||||
fn refused(raw: &mut Raw) -> String {
|
||||
match raw.next() {
|
||||
ServerMsg::Refused { reason } => reason,
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_requests_are_refused_and_never_hurt_the_server() {
|
||||
let dir = TempDir::new("codes-junk");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let url = format!("ws://{addr}/");
|
||||
let mut a = app("Ada", &url);
|
||||
ex(&mut a, "collab.share", json!({}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
let ask = |code: &str, protocol: u32| {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::Resolve(Resolve { protocol, client_version: String::new(), code: code.to_string() }));
|
||||
let why = refused(&mut raw);
|
||||
assert!(raw.closed_within(Duration::from_secs(5)), "the connection closes after the answer");
|
||||
why
|
||||
};
|
||||
// Junk in place of a code (each from this one address: after ten it is locked out).
|
||||
for junk in ["", " ", "💥💥💥", "K7M-2QX", "../../etc/passwd", "K7M2QX9TDK7M2QX9TD"] {
|
||||
assert_eq!(ask(junk, PROTOCOL), NO_CODE, "{junk:?}");
|
||||
}
|
||||
assert_eq!(ask(&"Z".repeat(4000), PROTOCOL), NO_CODE);
|
||||
assert!(ask("K7M2QX9TD", 1).contains("isn't supported"));
|
||||
// Asking for a code needs the file's owner token (or the key), and one of its link tokens.
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: "AAAAAAAAAAAAAAAAAAAAAA".into() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
|
||||
let mut raw = Raw::connect(addr);
|
||||
let owner = p.owner.clone().unwrap();
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
for token in ["", "AAAAAAAAAAAAAAAAAAAAAA", &"x".repeat(5000)] {
|
||||
raw.send(&ClientMsg::Admin(Admin::Code { file: p.file.clone(), token: token.to_string() }));
|
||||
assert!(
|
||||
matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("isn't one of this file's links")))
|
||||
);
|
||||
}
|
||||
// A code in place of a link's token joins nothing: codes only resolve.
|
||||
let mut raw = Raw::connect(addr);
|
||||
let shown = a.live_summary()["code"].as_str().unwrap().replace('-', "");
|
||||
let hello = Hello {
|
||||
protocol: PROTOCOL,
|
||||
file: p.file.clone(),
|
||||
token: shown,
|
||||
name: "Mal".into(),
|
||||
color: String::new(),
|
||||
client_version: String::new(),
|
||||
resume: None,
|
||||
};
|
||||
raw.send(&ClientMsg::Hello(hello));
|
||||
assert!(refused(&mut raw).contains("isn't valid"));
|
||||
// The session went on through all of it.
|
||||
assert_eq!(a.live_summary()["status"], "live");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_from_before_join_codes_are_served_as_before() {
|
||||
let dir = TempDir::new("codes-old-client");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
// An app that never asks for codes (`collab.publish`, what older apps do): links only, and
|
||||
// nothing about codes is kept for the file.
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.publish", json!({"server": url, "name": "Poster"}));
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
assert_eq!((&a.live_summary()["code"], &a.live_summary()["share"]), (&Value::Null, &Value::Null));
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
|
||||
assert!(!links.contains("\"code\""), "{links}");
|
||||
// Its links join as ever.
|
||||
let mut b = crate::test_util::join(&p.view, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
|
||||
server.stop();
|
||||
// A data directory from before join codes (links without the field) opens unchanged.
|
||||
let old = r#"[{"id":"0123456789abcdef","access":"edit","label":"Published (edit)","created":1,"hash":"00"}]"#;
|
||||
let path = dir.path().join("files").join(&p.file).join("links.json");
|
||||
std::fs::write(&path, old).unwrap();
|
||||
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
|
||||
assert_eq!(store.links(&p.file).unwrap().len(), 1);
|
||||
assert_eq!(store.resolve_code(ip("192.0.2.1"), "K7M2QX9TD").unwrap_err(), NO_CODE);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
pub mod abuse;
|
||||
pub mod cli;
|
||||
pub mod codes;
|
||||
pub mod config;
|
||||
pub mod directory;
|
||||
pub mod disk;
|
||||
@@ -90,6 +91,8 @@ mod background_tests;
|
||||
#[cfg(test)]
|
||||
mod cli_tests;
|
||||
#[cfg(test)]
|
||||
mod codes_tests;
|
||||
#[cfg(test)]
|
||||
mod directory_tests;
|
||||
#[cfg(test)]
|
||||
mod hardening_tests;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! <data>/server.json where the running server listens (for the command line)
|
||||
//! <data>/server.key the server key: managing files (publish, links, removal)
|
||||
//! <data>/files/<id>/meta.json name, created, updated, sequence number
|
||||
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token
|
||||
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token,
|
||||
//! and a join code's hash and wrapped token (see `codes`)
|
||||
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
|
||||
//! <data>/files/<id>/log.bin what changed since (see `disk`)
|
||||
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
|
||||
@@ -28,7 +29,7 @@
|
||||
//! hashes and compared in constant time. Whatever is damaged at startup is skipped and reported,
|
||||
//! never deleted.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::fs::{self, File, OpenOptions, TryLockError};
|
||||
use std::io::{Cursor, Write};
|
||||
use std::net::IpAddr;
|
||||
@@ -46,6 +47,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action};
|
||||
use crate::codes::{self, Sealed};
|
||||
use crate::directory::{Directory, DirectoryConfig};
|
||||
use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE};
|
||||
|
||||
@@ -58,6 +60,12 @@ pub const REPORTS: &str = "reports.json";
|
||||
/// Most addresses blocked, and most reports kept (the oldest go first).
|
||||
const MAX_BLOCKED: usize = 100_000;
|
||||
const MAX_REPORTS: usize = 1_000;
|
||||
/// Most join codes remembered as no longer valid.
|
||||
const MAX_GONE_CODES: usize = 10_000;
|
||||
/// What typing a join code that opens nothing is answered with, by why.
|
||||
pub const NO_CODE: &str = "No session with that code";
|
||||
pub const CODE_GONE: &str = "That code has expired or its sharing was stopped";
|
||||
pub const CODES_BUSY: &str = "Join codes are paused on this server for a while (too many wrong ones were tried): join with the invite link";
|
||||
/// Longest report reason kept (characters).
|
||||
const MAX_REASON: usize = 500;
|
||||
/// How often idle files are looked for (seconds), besides at startup.
|
||||
@@ -188,6 +196,9 @@ struct Link {
|
||||
created: u64,
|
||||
/// SHA-256 of the token, hex.
|
||||
hash: String,
|
||||
/// Its join code, when one was handed out (never the code itself).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
code: Option<Sealed>,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
@@ -245,6 +256,11 @@ pub struct Store {
|
||||
usage: BTreeMap<String, UsageCache>,
|
||||
blocked: BTreeSet<IpAddr>,
|
||||
reports: Vec<ReportInfo>,
|
||||
/// Hashes of join codes that no longer open anything (their link was revoked, their file
|
||||
/// removed or expired, or a newer code took their place): typing one says so.
|
||||
// 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>,
|
||||
/// When idle files are looked for next (seconds since 1970).
|
||||
next_sweep: u64,
|
||||
/// Compactions so far (tests count them).
|
||||
@@ -341,6 +357,7 @@ impl Store {
|
||||
usage: BTreeMap::new(),
|
||||
blocked: BTreeSet::new(),
|
||||
reports: Vec::new(),
|
||||
gone_codes: VecDeque::new(),
|
||||
next_sweep: 0,
|
||||
compactions: 0,
|
||||
free: None,
|
||||
@@ -755,6 +772,101 @@ impl Store {
|
||||
Ok(NewLink { link: link.info(file), token })
|
||||
}
|
||||
|
||||
/// The link of `entry` whose token `token` is (every link is compared, in constant time, so
|
||||
/// timing doesn't tell which one matched).
|
||||
fn link_of(entry: &Entry, token: &str) -> Option<usize> {
|
||||
let presented = hash(token);
|
||||
let mut found = None;
|
||||
for (i, l) in entry.links.iter().enumerate() {
|
||||
if same_secret(&presented, &l.hash) {
|
||||
found = Some(i);
|
||||
}
|
||||
}
|
||||
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));
|
||||
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).
|
||||
fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
|
||||
let mut found = None;
|
||||
for (id, e) in &self.files {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// A new join code for the link of `file` whose token is `token` (it takes the place of the
|
||||
/// link's earlier code): the code, shown this once.
|
||||
pub fn set_code(&mut self, file: &str, token: &str) -> Result<String, String> {
|
||||
let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
|
||||
// 45 random bits: one already in use is all but impossible, and never handed out twice.
|
||||
let (code, key) = loop {
|
||||
let code = codes::new_code()?;
|
||||
let key = codes::Key::new(&code);
|
||||
if self.find_code(&key).is_none() {
|
||||
break (code, key);
|
||||
}
|
||||
};
|
||||
let sealed = key.seal(token)?;
|
||||
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")?;
|
||||
let before = link.code.replace(sealed);
|
||||
let id = link.id.clone();
|
||||
if let Err(e) = write_json(&path, &entry.links) {
|
||||
if let Some(l) = entry.links.get_mut(at) {
|
||||
l.code = before;
|
||||
}
|
||||
return Err(format!("couldn't keep the join code: {e}"));
|
||||
}
|
||||
self.forget_codes(before);
|
||||
log::info!("event=code file={file} link={id}");
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
|
||||
/// the address (see `abuse`), and nothing is looked up for an address that is locked out.
|
||||
pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
|
||||
let now = Instant::now();
|
||||
if let Some(left) = self.abuse.code_locked(ip, now) {
|
||||
return Err(format!(
|
||||
"Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
|
||||
left.as_secs() / 60 + 1
|
||||
));
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Code, now) {
|
||||
return Err("Too many codes tried from your address: wait a minute and try again".into());
|
||||
}
|
||||
if !self.abuse.codes_open(now) {
|
||||
return Err(CODES_BUSY.into());
|
||||
}
|
||||
// Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
|
||||
let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
|
||||
let found = key.as_ref().and_then(|k| self.find_code(k));
|
||||
self.abuse.code_tried(ip, found.is_some(), now);
|
||||
match found {
|
||||
Some((file, _, token)) => {
|
||||
log::info!("event=code_used file={file} ip={ip}");
|
||||
Ok((file, token))
|
||||
}
|
||||
None => {
|
||||
let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
|
||||
Err(if gone { CODE_GONE } else { NO_CODE }.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke a link.
|
||||
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
|
||||
let path = self.dir(file).join(LINKS);
|
||||
@@ -766,6 +878,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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -774,6 +887,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()));
|
||||
drop(entry);
|
||||
let dir = self.dir(file);
|
||||
fs::remove_dir_all(&dir)
|
||||
@@ -1112,21 +1226,15 @@ fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), Strin
|
||||
/// A new link and its token.
|
||||
fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), String> {
|
||||
let token = token::secret()?;
|
||||
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token) }, token))
|
||||
Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token))
|
||||
}
|
||||
|
||||
impl Backend for Store {
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
let entry = self.entry(file)?;
|
||||
let presented = hash(token);
|
||||
// Every link is compared (in constant time), so timing doesn't tell which one matched.
|
||||
let mut found = None;
|
||||
for l in &entry.links {
|
||||
if same_secret(&presented, &l.hash) {
|
||||
found = Some(Admission { access: l.access, link: Some(l.id.clone()) });
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
|
||||
let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i));
|
||||
link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) })
|
||||
.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
|
||||
}
|
||||
|
||||
fn open(&mut self, file: &str) -> Result<Server, String> {
|
||||
@@ -1261,6 +1369,7 @@ impl Backend for Store {
|
||||
| Admin::CreateLink { file, .. }
|
||||
| Admin::Revoke { file, .. }
|
||||
| Admin::Rename { file, .. }
|
||||
| Admin::Code { file, .. }
|
||||
| Admin::Remove { file } => file,
|
||||
_ => return failed("that needs the server key"),
|
||||
};
|
||||
@@ -1297,6 +1406,7 @@ impl Backend for Store {
|
||||
},
|
||||
Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
|
||||
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
|
||||
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
|
||||
Admin::Remove { file } => {
|
||||
if !self.files.contains_key(&file) {
|
||||
return failed("this file isn't on the server (it may have been removed)".into());
|
||||
@@ -1342,6 +1452,10 @@ impl Backend for Store {
|
||||
self.directory.handle(publisher, req)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> {
|
||||
self.resolve_code(ip, code)
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Restarting
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Join codes over the network: a file shared on the local network joined by its code (two
|
||||
//! engine sessions over loopback), what the browser's driver does with an online code, a server
|
||||
//! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out
|
||||
//! and resolving them is tested in its own crate, with the same link.)
|
||||
|
||||
use std::net::{Ipv4Addr, TcpListener};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::code::{self, Code, Local};
|
||||
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, ServerMsg};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use serde_json::json;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::driver::{Driver, Socket};
|
||||
use crate::guest::GuestConfig;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::loopback_tests::{fast_limits, quick};
|
||||
use crate::{NetLink, manage};
|
||||
|
||||
fn session(name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
|
||||
s.execute("pref.userName", &json!({"name": name})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Pump the sessions (as their windows would every frame) until `f` holds.
|
||||
fn pump(all: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session]) -> bool) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
for s in all.iter_mut() {
|
||||
s.live_poll();
|
||||
s.live_sync();
|
||||
}
|
||||
if f(all) {
|
||||
return;
|
||||
}
|
||||
let states: Vec<String> = all.iter().map(|s| s.live_summary().to_string()).collect();
|
||||
assert!(start.elapsed() < Duration::from_secs(15), "timed out waiting for {what}: {states:#?}");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn live(s: &Session) -> bool {
|
||||
s.live().status == Status::Live && s.live().syncing()
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().map_or(0, |d| d.node_count())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
let (mut host, mut guest) = (session("Hana"), session("Ada"));
|
||||
// This machine only, so the test needs no network; the code says where and the secret.
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap();
|
||||
assert_eq!((&shared["status"], &shared["share"]["via"], &shared["share"]["access"]), (&json!("live"), &json!("local"), &json!("edit")));
|
||||
let shown = shared["code"].as_str().unwrap().to_string();
|
||||
assert_eq!(shown.len(), 23, "twenty symbols in fives: {shown}");
|
||||
let Code::Local(local) = code::parse(&shown).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!(local.ip, Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port));
|
||||
assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session");
|
||||
assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network");
|
||||
// Typed the way people type: lower case, spaces for the dashes.
|
||||
let typed = shown.to_lowercase().replace('-', " ");
|
||||
assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting");
|
||||
pump(&mut [&mut host, &mut guest], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
assert_eq!(guest.live_summary()["access"], "edit");
|
||||
// Edits go both ways.
|
||||
let before = layers(&host);
|
||||
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the host's edit", |s| layers(s[1]) == before + 1);
|
||||
guest.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the guest's edit", |s| layers(s[0]) == before + 2);
|
||||
// The link joins the same session (an older app, or a paste).
|
||||
let mut third = session("Bo");
|
||||
third.execute("collab.join", &json!({"link": shared["link"]})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest, &mut third], "the link's join", |s| live(s[2]) && layers(s[2]) == before + 2);
|
||||
// Stopping ends it: the code no longer joins.
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
pump(&mut [&mut host, &mut guest, &mut third], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active());
|
||||
let mut late = session("Cy");
|
||||
late.execute("collab.join", &json!({"code": shown})).unwrap();
|
||||
pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. }));
|
||||
assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
|
||||
let (mut host, mut guest) = (session("Hana"), session("Ada"));
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local"})).unwrap();
|
||||
let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
// A code for the same address with another secret: well formed, refused by the host.
|
||||
let guess = Local::new(local.ip, local.port, local.secret ^ 1).code();
|
||||
guest.execute("collab.join", &json!({"code": guess})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the refusal", |s| matches!(s[1].live().status, Status::Failed { .. }));
|
||||
assert!(guest.live().status.label().contains("wrong session token"), "{:?}", guest.live().status);
|
||||
assert!(host.live().peers.is_empty());
|
||||
// A typing mistake is caught before anything connects.
|
||||
let mut typo: Vec<char> = local.code().chars().collect();
|
||||
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
|
||||
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
|
||||
assert!(e.contains("typing mistake"), "{e}");
|
||||
// A view-only code doesn't exist here, and says where it does.
|
||||
let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string();
|
||||
assert!(e.contains("share it online"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_can_name_a_join_code() {
|
||||
let (invite, found) = Invite::parse_join("figcraft://join?server=wss://share.example/&code=k7m-2qx-9td").unwrap();
|
||||
assert_eq!((invite.server.url(), invite.file.as_str(), invite.token.as_str()), ("wss://share.example:443/".to_string(), "", ""));
|
||||
assert_eq!(found.as_deref(), Some("K7M2QX9TD"));
|
||||
let (invite, found) = Invite::parse_join(" figcraft://join?host=127.0.0.1:47900&code=K7M2QX9TD ").unwrap();
|
||||
assert_eq!((invite.server, found.as_deref()), (Endpoint::plain("127.0.0.1:47900"), Some("K7M2QX9TD")));
|
||||
// A link with a file and a token is read as before.
|
||||
let link = "figcraft://join?host=127.0.0.1:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
assert_eq!(Invite::parse_join(link).unwrap(), (Invite::parse(link).unwrap(), None));
|
||||
for bad in [
|
||||
"figcraft://join?server=wss://share.example/&code=",
|
||||
"figcraft://join?server=wss://share.example/&code=K7M",
|
||||
"figcraft://join?code=K7M2QX9TD",
|
||||
] {
|
||||
assert!(Invite::parse_join(bad).is_err(), "{bad}");
|
||||
}
|
||||
// Where a file's link is needed (team libraries), a code isn't one.
|
||||
let e = Invite::parse("figcraft://join?server=wss://share.example/&code=K7M2QX9TD").unwrap_err();
|
||||
assert!(e.contains("join code") && !e.contains("K7M"), "{e}");
|
||||
}
|
||||
|
||||
/// A server from before join codes: whatever it is asked first, it can't read it (what its
|
||||
/// decoder says about a message it doesn't know).
|
||||
fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
|
||||
let t = std::thread::spawn(move || {
|
||||
for stream in listener.incoming().take(3).flatten() {
|
||||
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
|
||||
let _ = ws.read();
|
||||
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
|
||||
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap())));
|
||||
let _ = ws.close(None);
|
||||
while ws.read().is_ok() {}
|
||||
}
|
||||
});
|
||||
(at, t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_older_than_join_codes_says_so_and_the_links_still_work() {
|
||||
let (server, thread) = old_server();
|
||||
let limits = fast_limits();
|
||||
// Asking it about a code: the join fails with what to do instead.
|
||||
let e = manage::resolve(&server, "K7M2QX9TD", &limits, Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.starts_with(manage::REFUSED) && e.contains("couldn't be read"), "{e}");
|
||||
let mut guest = session("Ada");
|
||||
guest.execute("pref.publishServer", &json!({"url": server.url()})).unwrap();
|
||||
guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap();
|
||||
pump(&mut [&mut guest], "the answer", |s| matches!(s[0].live().status, Status::Failed { .. }));
|
||||
let why = guest.live().status.label();
|
||||
assert!(why.contains("doesn't know join codes yet") && why.contains("invite link"), "{why}");
|
||||
// Asking it for codes: none, without an error (the app shows the links).
|
||||
assert_eq!(
|
||||
manage::codes(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &["AbCdEfGhIjKlMnOpQrStUv"], &limits, Duration::from_secs(5)),
|
||||
None
|
||||
);
|
||||
thread.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_cant_be_reached_says_so() {
|
||||
// A port nothing listens on.
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let mut guest = session("Ada");
|
||||
guest.execute("pref.publishServer", &json!({"url": format!("ws://127.0.0.1:{port}/")})).unwrap();
|
||||
guest.execute("collab.join", &json!({"code": "K7M-2QX-9TD"})).unwrap();
|
||||
pump(&mut [&mut guest], "the failure", |s| matches!(s[0].live().status, Status::Failed { .. }));
|
||||
assert!(guest.live().status.label().starts_with("Couldn't reach the server"), "{:?}", guest.live().status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_app_hosted_session_knows_no_online_codes() {
|
||||
let mut host = session("Hana");
|
||||
let shared = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
||||
let server = Endpoint::plain(shared["address"].as_str().unwrap());
|
||||
let e = manage::resolve(&server, "K7M2QX9TD", &fast_limits(), Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("hosted by an app"), "{e}");
|
||||
// Junk in place of a code is refused the same way, and the session goes on.
|
||||
for junk in ["", "💥", &"Z".repeat(4000)] {
|
||||
assert!(manage::resolve(&server, junk, &fast_limits(), Duration::from_secs(5)).is_err());
|
||||
}
|
||||
assert_eq!(host.live_summary()["status"], "live");
|
||||
}
|
||||
|
||||
/// A socket that records what the driver does (the browser's, scripted).
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's driver joining with a code at a server.
|
||||
fn driver(t0: web_time::Instant) -> (Driver<Script>, Inbox) {
|
||||
let (invite, found) = Invite::parse_join("figcraft://join?server=ws://127.0.0.1:47900/&code=K7M2QX9TD").unwrap();
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let cfg = GuestConfig { code: found, ..GuestConfig::new(invite, "Ada".into()) };
|
||||
(Driver::start(Script::default(), cfg, inbox.clone(), t0), inbox)
|
||||
}
|
||||
|
||||
fn failure(inbox: &Inbox) -> String {
|
||||
inbox.drain().into_iter().find_map(|e| if let Event::Status(Status::Failed { reason }) = e { Some(reason) } else { None }).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_asks_about_the_code_then_joins_the_file() {
|
||||
let t0 = web_time::Instant::now();
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.opened(t0);
|
||||
let [ClientMsg::Resolve(ask)] = d.sock.sent.as_slice() else { panic!("asked {:?}", d.sock.sent) };
|
||||
assert_eq!((ask.protocol, ask.code.as_str()), (PROTOCOL, "K7M2QX9TD"));
|
||||
// The answer: a new connection to the same server, and a hello for the file.
|
||||
let answer = ServerMsg::Resolved { file: "0123456789abcdef".into(), token: "AbCdEfGhIjKlMnOpQrStUv".into() };
|
||||
d.message(&message::encode(&answer).unwrap(), t0);
|
||||
assert_eq!((d.sock.opened.len(), d.sock.closes), (2, 1));
|
||||
assert!(!d.done());
|
||||
d.opened(t0);
|
||||
let Some(ClientMsg::Hello(hello)) = d.sock.sent.last() else { panic!("sent {:?}", d.sock.sent) };
|
||||
assert_eq!((hello.file.as_str(), hello.token.as_str(), hello.name.as_str()), ("0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", "Ada"));
|
||||
assert!(failure(&inbox).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_shows_why_a_code_didnt_join() {
|
||||
let t0 = web_time::Instant::now();
|
||||
// The server's own words for a code it doesn't know.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.opened(t0);
|
||||
d.message(&message::encode(&ServerMsg::Refused { reason: "No session with that code".into() }).unwrap(), t0);
|
||||
assert!(d.done());
|
||||
assert_eq!(failure(&inbox), "No session with that code");
|
||||
// An older server.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.opened(t0);
|
||||
d.message(&message::encode(&ServerMsg::Refused { reason: "that message couldn't be read".into() }).unwrap(), t0);
|
||||
assert!(failure(&inbox).contains("doesn't know join codes yet"));
|
||||
// Nobody answers: no retries while still asking.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.tick(t0 + Duration::from_secs(30));
|
||||
assert!(d.done());
|
||||
assert!(failure(&inbox).starts_with("Couldn't reach the server"));
|
||||
// An answer that isn't a file.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.opened(t0);
|
||||
d.message(&message::encode(&ServerMsg::Resolved { file: "../etc".into(), token: "short".into() }).unwrap(), t0);
|
||||
assert!(d.done());
|
||||
assert!(failure(&inbox).contains("isn't a file"));
|
||||
// Leaving while asking is just leaving.
|
||||
let (mut d, inbox) = driver(t0);
|
||||
d.leave(t0);
|
||||
assert!(d.done() && failure(&inbox).is_empty());
|
||||
}
|
||||
@@ -5,14 +5,18 @@
|
||||
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
|
||||
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
|
||||
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
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::inbox::Inbox;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
@@ -93,12 +97,15 @@ impl<S: Socket> Driver<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection is open: say hello.
|
||||
/// The connection is open: say hello (or ask about the join code first).
|
||||
pub(crate) fn opened(&mut self, now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
c.opened = true;
|
||||
c.last_heard = now;
|
||||
let hello = hello(&self.cfg, &self.resume);
|
||||
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),
|
||||
};
|
||||
if !self.write(&hello, now) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
@@ -109,6 +116,16 @@ impl<S: Socket> Driver<S> {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
c.last_heard = now;
|
||||
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
// 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() => {
|
||||
return match resolved(&mut self.cfg, file, token) {
|
||||
Ok(()) => {
|
||||
self.sock.close();
|
||||
self.connect(now);
|
||||
}
|
||||
Err(o) => self.end(o, now),
|
||||
};
|
||||
}
|
||||
// Single-threaded: the connection counts as welcomed below, before the engine polls.
|
||||
Ok(m) => take(m, &self.cfg, &self.inbox, &mut c.seen, &self.resume, &|| {}),
|
||||
Err(e) => Taken::End(Outcome::Refused(format!("the host sent a message this version can't read ({e})"))),
|
||||
@@ -233,6 +250,8 @@ impl<S: Socket> Driver<S> {
|
||||
|
||||
/// The connection ended: close it, then stop or plan the reconnect.
|
||||
fn end(&mut self, outcome: Outcome, now: Instant) {
|
||||
// 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() {
|
||||
self.sock.close();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
pub use crate::join::GuestConfig;
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::manage;
|
||||
use crate::net::{Duplex, NetLimits, config, open, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
@@ -39,6 +39,8 @@ pub struct Publish {
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
pub doc: Arc<Document>,
|
||||
/// Ask the server for join codes for the file's links ([`Event::Codes`]).
|
||||
pub codes: bool,
|
||||
}
|
||||
|
||||
/// A joined (or joining) session. Dropping it leaves.
|
||||
@@ -60,10 +62,27 @@ enum Cmd {
|
||||
|
||||
impl Guest {
|
||||
/// Start joining (returns at once; progress arrives in `inbox`).
|
||||
pub fn start(cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
|
||||
pub fn start(mut cfg: GuestConfig, inbox: Inbox) -> io::Result<Guest> {
|
||||
let (tx, rx) = channel();
|
||||
let me = tx.clone();
|
||||
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || supervise(&cfg, &inbox, &rx, &me))?;
|
||||
let thread = std::thread::Builder::new().name("live-guest".into()).spawn(move || {
|
||||
// A join code: the server says which file it opens, then the join goes on as with a link.
|
||||
if let Some(code) = cfg.code.clone() {
|
||||
inbox.push(Event::Status(Status::Connecting));
|
||||
let asked =
|
||||
manage::resolve(&cfg.invite.server, &code, &cfg.limits, cfg.connect_timeout).map_err(|e| match e.strip_prefix(manage::REFUSED) {
|
||||
Some(why) => Outcome::Refused(why.to_string()),
|
||||
None => Outcome::Retry(e),
|
||||
});
|
||||
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
|
||||
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
|
||||
inbox.push(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
supervise(&cfg, &inbox, &rx, &me);
|
||||
})?;
|
||||
Ok(Guest { cmd: tx, thread: Some(thread) })
|
||||
}
|
||||
|
||||
@@ -79,10 +98,16 @@ impl Guest {
|
||||
Ok(done) => done,
|
||||
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
|
||||
};
|
||||
// Before anyone hears the file is there, so the codes show with it (a server from
|
||||
// before join codes has none: the links are shown).
|
||||
let codes = if job.codes { join_codes(&job, &cfg, &published) } else { None };
|
||||
if let Some(h) = hosting {
|
||||
inbox.push(Event::Hosting(h));
|
||||
}
|
||||
inbox.push(Event::Published(published));
|
||||
if let Some((edit, view)) = codes {
|
||||
inbox.push(Event::Codes { edit, view });
|
||||
}
|
||||
// Left while uploading: the file stays on the server, nobody joins it. Anything else
|
||||
// that came meanwhile (presence) waits for the session.
|
||||
let mut waiting = Vec::new();
|
||||
@@ -181,6 +206,16 @@ fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published
|
||||
Ok((cfg, published, hosting))
|
||||
}
|
||||
|
||||
/// The join codes of the file just published: its edit link's and its view link's.
|
||||
fn join_codes(job: &Publish, cfg: &GuestConfig, published: &Published) -> Option<(String, String)> {
|
||||
let view = Invite::parse(&published.view).ok()?;
|
||||
let tokens = [cfg.invite.token.as_str(), view.token.as_str()];
|
||||
let owner = published.owner.as_deref().unwrap_or_default();
|
||||
let codes = manage::codes(&job.server, job.key.as_deref(), &published.file, owner, &tokens, &cfg.limits, cfg.connect_timeout)?;
|
||||
let mut codes = codes.into_iter();
|
||||
Some((codes.next()?, codes.next()?))
|
||||
}
|
||||
|
||||
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
|
||||
let (rd, wr, sock) = open(&cfg.invite.server, cfg.connect_timeout, &cfg.limits)?;
|
||||
let (reader, _) = tungstenite::client::client_with_config(cfg.invite.endpoint(), rd, Some(config(cfg.limits.max_server_message)))
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{
|
||||
self, Access, Admin, AdminReply, ClientMsg, Hello, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
|
||||
PluginReply, PluginReq, Presence, ServerMsg, reads_hosting,
|
||||
PluginReply, PluginReq, Presence, Resolve, ServerMsg, reads_hosting,
|
||||
};
|
||||
use figcraft_collab::{Outgoing, Server};
|
||||
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
|
||||
@@ -205,6 +205,12 @@ pub trait Backend: Send + 'static {
|
||||
Err("this session is hosted by an app: tell the person hosting it".into())
|
||||
}
|
||||
|
||||
/// A join code typed by someone at `ip`: the file it opens and that link's token, or why not
|
||||
/// (shown to them; wrong codes are counted against the address).
|
||||
fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> {
|
||||
Err("this session is hosted by an app: join it with its own code or invite link".into())
|
||||
}
|
||||
|
||||
/// What everyone is told when the hub stops.
|
||||
fn closing(&self) -> Closing;
|
||||
}
|
||||
@@ -386,8 +392,8 @@ impl InFlight {
|
||||
}
|
||||
|
||||
enum HubIn {
|
||||
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins` or
|
||||
/// `OpenHosting`) and its client's address ([`ip_key`]).
|
||||
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`,
|
||||
/// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]).
|
||||
Open {
|
||||
conn: u64,
|
||||
ip: IpAddr,
|
||||
@@ -907,7 +913,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
| ClientMsg::Manage(_)
|
||||
| ClientMsg::OpenLibrary(_)
|
||||
| ClientMsg::OpenPlugins(_)
|
||||
| ClientMsg::OpenHosting(_)),
|
||||
| ClientMsg::OpenHosting(_)
|
||||
| ClientMsg::Resolve(_)),
|
||||
bytes,
|
||||
))) => {
|
||||
// Sessions and the operator may use the last connections; one-shot requests
|
||||
@@ -1171,6 +1178,7 @@ impl<B: Backend> Hub<B> {
|
||||
ClientMsg::OpenLibrary(o) => self.open_library(conn, o, c),
|
||||
ClientMsg::OpenPlugins(o) => self.open_plugins(conn, &o, c),
|
||||
ClientMsg::OpenHosting(o) => self.open_hosting(conn, &o, c),
|
||||
ClientMsg::Resolve(r) => self.resolve(&r, &c),
|
||||
_ => refuse(&c.out, "say hello first"),
|
||||
}
|
||||
}
|
||||
@@ -1408,6 +1416,26 @@ impl<B: Backend> Hub<B> {
|
||||
self.take_in(conn, c, Kind::Hosting { owns }, &ServerMsg::Hosting(Box::new(info)));
|
||||
}
|
||||
|
||||
/// A join code: the file it opens and the token to join it with, then the connection closes
|
||||
/// (it was never taken in).
|
||||
fn resolve(&mut self, r: &Resolve, c: &Pending) {
|
||||
if r.protocol != PROTOCOL {
|
||||
return refuse(&c.out, &unsupported(r.protocol));
|
||||
}
|
||||
match self.backend.resolve(c.ip, &r.code) {
|
||||
Ok((file, token)) => {
|
||||
if let Some(b) = encode(&ServerMsg::Resolved { file, token }) {
|
||||
let _ = c.out.tx.try_send(Out::Bytes(b, None));
|
||||
}
|
||||
let _ = c.out.tx.try_send(Out::Close(CloseCode::Normal, String::new()));
|
||||
}
|
||||
Err(why) => {
|
||||
log::info!("event=code_refused ip={} reason={why:?}", c.ip);
|
||||
refuse(&c.out, &why);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn admin(&mut self, conn: u64, req: Admin) {
|
||||
let rooms = &self.rooms;
|
||||
let open = |file: &str| rooms.get(file).map(Room::info);
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! connects to the endpoint and sends the token in its `Hello`. A server address for managing
|
||||
//! files carries the server's key the same way (`ws://host:port/?key=<key>`). Errors never echo a
|
||||
//! link or an address.
|
||||
//!
|
||||
//! A join by code names the server to ask and the code:
|
||||
//! `figcraft://join?server=<ws(s)://…>&code=<code>` ([`Invite::parse_join`]); the server says which
|
||||
//! file and token the code stands for.
|
||||
|
||||
/// Where a session or a server listens: `ws://host:port/path` or `wss://…` (TLS).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -134,6 +138,15 @@ impl Invite {
|
||||
|
||||
/// Read an invite link (any form; surrounding spaces, quotes or angle brackets are fine).
|
||||
pub fn parse(link: &str) -> Result<Invite, String> {
|
||||
match Self::parse_join(link)? {
|
||||
(invite, None) => Ok(invite),
|
||||
(_, Some(_)) => Err("that names a join code, not a file: join with it first".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read what a join starts from: an invite link, or a server with a join code to ask it about
|
||||
/// (then the invite names only the server, until the server says what the code opens).
|
||||
pub fn parse_join(link: &str) -> Result<(Invite, Option<String>), String> {
|
||||
let link = clean(link);
|
||||
if link.len() > MAX_LINK {
|
||||
return Err(NOT.into());
|
||||
@@ -158,9 +171,14 @@ impl Invite {
|
||||
return Err(NOT.into());
|
||||
};
|
||||
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
|
||||
if let Some(code) = get("code") {
|
||||
let code = figcraft_collab::code::normalize(&code).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN);
|
||||
let code = code.ok_or("the link's join code isn't one")?;
|
||||
return Ok((Invite { server, file: String::new(), token: String::new() }, Some(code)));
|
||||
}
|
||||
let file = get("file").filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
|
||||
let token = get("token").filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
|
||||
Ok(Invite { server, file, token })
|
||||
Ok((Invite { server, file, token }, None))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ pub(crate) const CLOSE_RESTART: u16 = 1012;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GuestConfig {
|
||||
pub invite: Invite,
|
||||
/// A join code to ask `invite.server` about first: its answer fills in the invite's file and
|
||||
/// token ([`resolved`]).
|
||||
pub code: Option<String>,
|
||||
/// Your name (empty: the host picks one).
|
||||
pub name: String,
|
||||
pub limits: NetLimits,
|
||||
@@ -44,6 +47,7 @@ impl GuestConfig {
|
||||
pub fn new(invite: Invite, name: String) -> Self {
|
||||
Self {
|
||||
invite,
|
||||
code: None,
|
||||
name,
|
||||
limits: NetLimits::default(),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
@@ -67,10 +71,36 @@ pub(crate) enum Outcome {
|
||||
Refused(String),
|
||||
/// The host ended the session: final.
|
||||
Ended(String),
|
||||
/// It can't go on, for this reason as it is shown: final.
|
||||
Failed(String),
|
||||
/// You left.
|
||||
Left,
|
||||
}
|
||||
|
||||
/// The server couldn't be asked about a join code, or said no: final, in words for the person who
|
||||
/// typed it (the server's own for a code it doesn't know).
|
||||
pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
|
||||
Outcome::Failed(match outcome {
|
||||
// A server from before join codes can't read the question.
|
||||
Outcome::Refused(why) if why.contains("couldn't be read") => {
|
||||
format!("{} doesn't know join codes yet (it runs an older version): ask for the invite link instead", cfg.invite.server.display())
|
||||
}
|
||||
Outcome::Refused(why) | Outcome::Failed(why) | Outcome::Ended(why) => why,
|
||||
Outcome::Retry(why) => format!("Couldn't reach the server ({why})"),
|
||||
Outcome::Left => return Outcome::Left,
|
||||
})
|
||||
}
|
||||
|
||||
/// The server's answer to the join code: the invite is whole now.
|
||||
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
|
||||
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
|
||||
return Err(Outcome::Failed("the server answered the join code with something that isn't a file".into()));
|
||||
}
|
||||
cfg.code = None;
|
||||
(cfg.invite.file, cfg.invite.token) = (file, token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a close frame from the host means.
|
||||
pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
|
||||
match code {
|
||||
@@ -97,6 +127,7 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
|
||||
match outcome {
|
||||
Outcome::Left => Next::Stop(None),
|
||||
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
|
||||
Outcome::Failed(why) => failed(why),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
|
||||
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
|
||||
Outcome::Retry(why) => {
|
||||
@@ -211,8 +242,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
|
||||
ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)),
|
||||
ServerMsg::Hosting(info) => inbox.push(Event::Hosting(*info)),
|
||||
ServerMsg::Reported { error } => inbox.push(Event::Reported(error)),
|
||||
// Answers for managing and library connections: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => {}
|
||||
// Answers for managing and library connections, and to a join code: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) | ServerMsg::Resolved { .. } => {}
|
||||
}
|
||||
Taken::Continue
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ pub use link::{DEFAULT_PORT, NetLink, lan_ip};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::NetLink;
|
||||
|
||||
#[cfg(test)]
|
||||
mod code_tests;
|
||||
#[cfg(test)]
|
||||
mod driver_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_engine::live::{
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionId,
|
||||
@@ -72,9 +73,9 @@ impl NetLink {
|
||||
self
|
||||
}
|
||||
|
||||
/// How this link joins `invite`.
|
||||
fn guest_config(&self, invite: Invite, name: &str) -> GuestConfig {
|
||||
let mut cfg = (self.guest)(GuestConfig::new(invite, name.to_string()));
|
||||
/// How this link joins `invite` (after asking its server about `code`, if there is one).
|
||||
fn guest_config(&self, invite: Invite, code: Option<String>, name: &str) -> GuestConfig {
|
||||
let mut cfg = (self.guest)(GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) });
|
||||
cfg.limits = self.limits;
|
||||
cfg
|
||||
}
|
||||
@@ -119,7 +120,13 @@ impl LiveLink for NetLink {
|
||||
Bind::Lan => lan_ip().ok_or("this machine has no local network address (connect to a network, or share on this machine only)")?,
|
||||
Bind::Addr(ip) => ip,
|
||||
};
|
||||
let (file, token) = (token::file_id()?, token::secret()?);
|
||||
// A session shared with a join code: its secret is short enough to be typed with the
|
||||
// address (see `figcraft_collab::code`); the code and the invite link name the same session.
|
||||
let (file, token) = if o.short {
|
||||
(code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token())
|
||||
} else {
|
||||
(token::file_id()?, token::secret()?)
|
||||
};
|
||||
let inbox = self.sessions.inbox();
|
||||
let doc = (*o.doc).clone();
|
||||
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
|
||||
@@ -154,8 +161,8 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
|
||||
let invite = Invite::parse(link)?;
|
||||
let cfg = self.guest_config(invite, name);
|
||||
let (invite, code) = Invite::parse_join(link)?;
|
||||
let cfg = self.guest_config(invite, code, name);
|
||||
let inbox = self.sessions.inbox();
|
||||
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.started(session, Active::Guest(guest), inbox);
|
||||
@@ -167,9 +174,9 @@ impl LiveLink for NetLink {
|
||||
// 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());
|
||||
// Until the upload is done the invite names only the server.
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
|
||||
let inbox = self.sessions.inbox();
|
||||
let job = Publish { server, key, name: o.name, doc: o.doc };
|
||||
let job = Publish { server, key, name: o.name, doc: o.doc, codes: o.codes };
|
||||
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.started(session, Active::Guest(guest), inbox);
|
||||
Ok(())
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{
|
||||
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
|
||||
PluginReq, ServerMsg,
|
||||
PluginReq, Resolve, ServerMsg,
|
||||
};
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
@@ -80,6 +80,51 @@ pub fn hosting(
|
||||
Ok((info, replies))
|
||||
}
|
||||
|
||||
/// Ask the server at `server` which file join code `code` opens: the file's id and its link's
|
||||
/// token. Fails with the server's own words when it knows no such code (or the address tried too
|
||||
/// many: the error starts with [`REFUSED`]); a server older than join codes can't read the
|
||||
/// question ("couldn't be read").
|
||||
pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Duration) -> Result<(String, String), String> {
|
||||
let ask = Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.to_string() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::Resolve(ask))?;
|
||||
let answer = reply(&mut ws, limits, |m| match m {
|
||||
ServerMsg::Resolved { file, token } => Some((file, token)),
|
||||
_ => None,
|
||||
})?;
|
||||
close(ws);
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Join codes for the links of `file` with these `tokens`, in order, asked as whoever manages the
|
||||
/// file: with the server's `key`, else with the file's `owner` token. `None` when the server
|
||||
/// doesn't hand codes out (it is older than them) or can't be asked: the links still work.
|
||||
pub fn codes(
|
||||
server: &Endpoint,
|
||||
key: Option<&str>,
|
||||
file: &str,
|
||||
owner: &str,
|
||||
tokens: &[&str],
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Option<Vec<String>> {
|
||||
let requests = tokens.iter().map(|t| Admin::Code { file: file.to_string(), token: (*t).to_string() }).collect();
|
||||
let replies = match key {
|
||||
Some(key) => request(server, key, requests, limits, timeout),
|
||||
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
|
||||
};
|
||||
let codes: Option<Vec<String>> = replies
|
||||
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.map(|r| match r {
|
||||
AdminReply::Code { code } => Some(code),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
codes.filter(|c| c.len() == tokens.len())
|
||||
}
|
||||
|
||||
/// Send one library request about the file behind share link `link`; the reply. Fails when the
|
||||
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
|
||||
/// [`LibraryReply::Failed`] reply). Never echoes the link.
|
||||
@@ -148,12 +193,15 @@ 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 {
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
|
||||
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
|
||||
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
|
||||
Ok(ServerMsg::Pong) => continue,
|
||||
Ok(m) => pick(m).ok_or_else(|| "the server sent something other than an answer".into()),
|
||||
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
|
||||
|
||||
@@ -15,3 +15,10 @@ pub fn file_id() -> Result<String, String> {
|
||||
getrandom::fill(&mut b).map_err(|e| format!("no secure random numbers: {e}"))?;
|
||||
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
|
||||
}
|
||||
|
||||
/// 64 random bits (join codes are made from them).
|
||||
pub fn random() -> Result<u64, String> {
|
||||
let mut b = [0u8; 8];
|
||||
getrandom::fill(&mut b).map_err(|e| format!("no secure random numbers: {e}"))?;
|
||||
Ok(u64::from_le_bytes(b))
|
||||
}
|
||||
|
||||
@@ -168,13 +168,14 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
|
||||
let invite = Invite::parse(link)?;
|
||||
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));
|
||||
let inbox = self.sessions.inbox();
|
||||
let driver = Driver::start(BrowserSocket { id, ws: None }, GuestConfig::new(invite, name.to_string()), inbox.clone(), Instant::now());
|
||||
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();
|
||||
|
||||
@@ -451,7 +451,9 @@ impl Client {
|
||||
}
|
||||
// Cursor chat, spotlights and what a server says about its hosting are the app's (the
|
||||
// transport hands them over).
|
||||
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } => Ok(Vec::new()),
|
||||
ServerMsg::Pong | ServerMsg::Signal { .. } | ServerMsg::Hosting(_) | ServerMsg::Reported { .. } | ServerMsg::Resolved { .. } => {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
|
||||
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
|
||||
if session != self.session || client_id != self.id {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Join codes: the short text someone types to join a shared file, in place of an invite link.
|
||||
//!
|
||||
//! Both kinds use Crockford's base32 alphabet (digits and capitals without `I`, `L`, `O`, `U`), so
|
||||
//! a code survives being read aloud or copied by hand: reading one ignores case, spaces and dashes
|
||||
//! and takes `O` as `0` and `I` or `L` as `1`.
|
||||
//!
|
||||
//! - An **online** code ([`ONLINE_LEN`] symbols, 45 random bits, shown `K7M-2QX-9TD`) is a name a
|
||||
//! server gave one share link: the server is asked which file it opens.
|
||||
//! - A **local network** code ([`LOCAL_LEN`] symbols, shown in fives) has no server to ask, so it
|
||||
//! carries what a joiner needs ([`Local`]): the host's IPv4 address, its port, the session's
|
||||
//! 44-bit secret and 8 check bits that catch typing mistakes before anything connects.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Crockford's base32 symbols, by value.
|
||||
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/// Symbols in an online code.
|
||||
pub const ONLINE_LEN: usize = 9;
|
||||
/// Symbols in a local network code.
|
||||
pub const LOCAL_LEN: usize = 20;
|
||||
/// Bits of a local session's secret.
|
||||
const SECRET_BITS: u32 = 44;
|
||||
/// Longest text read as a code (anything longer isn't one).
|
||||
const MAX_TYPED: usize = 128;
|
||||
|
||||
/// The file id every local session uses (its port tells sessions apart).
|
||||
pub const LOCAL_FILE: &str = "local";
|
||||
const TOKEN_PREFIX: &str = "local-";
|
||||
|
||||
/// The value of a typed symbol: its own, or the digit it is mistaken for.
|
||||
fn value(c: char) -> Option<u8> {
|
||||
let c = match c.to_ascii_uppercase() {
|
||||
'O' => '0',
|
||||
'I' | 'L' => '1',
|
||||
c => c,
|
||||
};
|
||||
ALPHABET.iter().position(|a| char::from(*a) == c).and_then(|i| u8::try_from(i).ok())
|
||||
}
|
||||
|
||||
/// The symbol for the low five bits of `v`.
|
||||
fn symbol(v: u8) -> char {
|
||||
char::from(ALPHABET.get(usize::from(v & 31)).copied().unwrap_or(b'0'))
|
||||
}
|
||||
|
||||
/// The lowest `n` symbols of `v`, most significant first.
|
||||
fn symbols(v: u128, n: usize) -> String {
|
||||
(0..n).rev().map(|i| symbol((v >> (5 * i)).to_le_bytes()[0])).collect()
|
||||
}
|
||||
|
||||
/// What someone typed, as a code's symbols: capitals, without spaces and dashes, look-alikes
|
||||
/// read as the digits they stand for. Errors say what to fix (they never echo the text).
|
||||
pub fn normalize(text: &str) -> Result<String, String> {
|
||||
if text.len() > MAX_TYPED {
|
||||
return Err("that is too long to be a join code".into());
|
||||
}
|
||||
let typed = text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '-' | '–' | '—' | '_' | '.'));
|
||||
let code = typed
|
||||
.map(|c| value(c).map(symbol))
|
||||
.collect::<Option<String>>()
|
||||
.ok_or("a join code has only letters and digits (and no U): check it and try again")?;
|
||||
if code.is_empty() {
|
||||
return Err("type the join code you were given".into());
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// A code as it is shown: in groups, with dashes (`K7M-2QX-9TD`; local codes in fives).
|
||||
pub fn grouped(code: &str) -> String {
|
||||
let n = if code.len() == ONLINE_LEN { 3 } else { 5 };
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
chars.chunks(n).map(|c| c.iter().collect::<String>()).collect::<Vec<_>>().join("-")
|
||||
}
|
||||
|
||||
/// An online code from 45 random bits (the rest of `random` is ignored).
|
||||
pub fn online(random: u64) -> String {
|
||||
symbols(u128::from(random), ONLINE_LEN)
|
||||
}
|
||||
|
||||
/// What a local network code carries.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Local {
|
||||
pub ip: Ipv4Addr,
|
||||
pub port: u16,
|
||||
/// The session's secret ([`SECRET_BITS`] bits).
|
||||
pub secret: u64,
|
||||
}
|
||||
|
||||
impl Local {
|
||||
/// For a session at `ip:port` with a secret made from `random` (its low 44 bits).
|
||||
pub fn new(ip: Ipv4Addr, port: u16, random: u64) -> Self {
|
||||
Self { ip, port, secret: random & ((1 << SECRET_BITS) - 1) }
|
||||
}
|
||||
|
||||
/// Address, port and secret as one number (92 bits).
|
||||
fn bits(&self) -> u128 {
|
||||
(u128::from(u32::from(self.ip)) << 60) | (u128::from(self.port) << SECRET_BITS) | u128::from(self.secret)
|
||||
}
|
||||
|
||||
/// The code's symbols (show them [`grouped`]).
|
||||
pub fn code(&self) -> String {
|
||||
let bits = self.bits();
|
||||
symbols((bits << 8) | u128::from(check(bits)), LOCAL_LEN)
|
||||
}
|
||||
|
||||
/// Read a [`normalize`]d local code.
|
||||
fn read(code: &str) -> Result<Local, String> {
|
||||
const TYPO: &str = "that local network code has a typing mistake: check it and try again";
|
||||
let v = code.chars().try_fold(0u128, |v, c| value(c).map(|s| (v << 5) | u128::from(s))).ok_or(TYPO)?;
|
||||
let bits = v >> 8;
|
||||
let local = Local {
|
||||
ip: Ipv4Addr::from(u32::try_from(bits >> 60).map_err(|_| TYPO)?),
|
||||
port: u16::try_from((bits >> SECRET_BITS) & 0xFFFF).map_err(|_| TYPO)?,
|
||||
secret: u64::try_from(bits & ((1 << SECRET_BITS) - 1)).map_err(|_| TYPO)?,
|
||||
};
|
||||
if u128::from(check(bits)) != v & 0xFF || local.port == 0 || local.ip.is_unspecified() {
|
||||
return Err(TYPO.into());
|
||||
}
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
/// The session's token: what its host checks a hello against.
|
||||
pub fn token(&self) -> String {
|
||||
format!("{TOKEN_PREFIX}{:011x}", self.secret)
|
||||
}
|
||||
|
||||
/// The session's invite link (what a joiner's app connects with).
|
||||
pub fn link(&self) -> String {
|
||||
format!("figcraft://join?host={}:{}&file={LOCAL_FILE}&token={}", self.ip, self.port, self.token())
|
||||
}
|
||||
|
||||
/// The session behind a local session's invite link (`None` for any other link: only IPv4
|
||||
/// hosts with a code-sized secret have a code).
|
||||
pub fn from_link(link: &str) -> Option<Local> {
|
||||
let query = link.strip_prefix("figcraft://join?")?;
|
||||
let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v));
|
||||
let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?;
|
||||
let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?;
|
||||
let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? };
|
||||
(get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local)
|
||||
}
|
||||
}
|
||||
|
||||
/// Eight check bits over a local code's 92 bits (FNV-1a, folded): not a secret, only a typo catcher.
|
||||
fn check(bits: u128) -> u8 {
|
||||
let h = bits.to_be_bytes().iter().fold(0x811C_9DC5_u32, |h, b| (h ^ u32::from(*b)).wrapping_mul(0x0100_0193));
|
||||
h.to_be_bytes().iter().fold(0, |a, b| a ^ b)
|
||||
}
|
||||
|
||||
/// A code someone typed, by kind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Code {
|
||||
/// A server's code (its symbols): the server says which file it opens.
|
||||
Online(String),
|
||||
Local(Local),
|
||||
}
|
||||
|
||||
/// Read what someone typed as a join code of either kind.
|
||||
pub fn parse(text: &str) -> Result<Code, String> {
|
||||
let code = normalize(text)?;
|
||||
match code.len() {
|
||||
ONLINE_LEN => Ok(Code::Online(code)),
|
||||
LOCAL_LEN => Local::read(&code).map(Code::Local),
|
||||
n => Err(format!("a join code has {ONLINE_LEN} characters ({LOCAL_LEN} on the local network); that one has {n}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "code_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Join codes: what typing tolerates, both kinds round trip, and tampered local codes are caught.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn typed_codes_are_normalised() {
|
||||
let table = [
|
||||
("K7M-2QX-9TD", Ok("K7M2QX9TD")),
|
||||
(" k7m 2qx 9td\n", Ok("K7M2QX9TD")),
|
||||
("k7m2qx9td", Ok("K7M2QX9TD")),
|
||||
("K7M–2QX—9TD", Ok("K7M2QX9TD")),
|
||||
("K7M_2QX.9TD", Ok("K7M2QX9TD")),
|
||||
// Look-alikes are the digits they are mistaken for.
|
||||
("O0o-IiL-l1X", Ok("00011111X")),
|
||||
("", Err("type the join code")),
|
||||
(" - ", Err("type the join code")),
|
||||
("K7M-2QU-9TD", Err("no U")),
|
||||
("K7M/2QX", Err("only letters and digits")),
|
||||
("K7M2QX9T€", Err("only letters and digits")),
|
||||
("figcraft://join?host=1.2.3.4:5&file=f&token=t", Err("only letters and digits")),
|
||||
];
|
||||
for (typed, want) in table {
|
||||
match (normalize(typed), want) {
|
||||
(Ok(got), Ok(want)) => assert_eq!(got, want, "{typed:?}"),
|
||||
(Err(e), Err(part)) => assert!(e.contains(part), "{typed:?}: {e}"),
|
||||
(got, want) => panic!("{typed:?}: got {got:?}, wanted {want:?}"),
|
||||
}
|
||||
}
|
||||
assert!(normalize(&"A".repeat(500)).is_err(), "too long to be a code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_never_echo_what_was_typed() {
|
||||
for typed in ["s3cr3t!!", "SECRETSECRETSECRETSECRE", "figcraft://join?token=hunter2hunter2hunter2"] {
|
||||
let e = parse(typed).unwrap_err();
|
||||
assert!(!e.to_lowercase().contains(&typed.to_lowercase()[..6]), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_codes_are_nine_unambiguous_symbols() {
|
||||
for random in [0, 1, u64::MAX, 0x1234_5678_9ABC_DEF0] {
|
||||
let code = online(random);
|
||||
assert_eq!(code.len(), ONLINE_LEN);
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit() || (c.is_ascii_uppercase() && !"ILOU".contains(c))), "{code}");
|
||||
assert_eq!(parse(&grouped(&code).to_lowercase()), Ok(Code::Online(code)));
|
||||
}
|
||||
assert_eq!(grouped("K7M2QX9TD"), "K7M-2QX-9TD");
|
||||
assert_ne!(online(1), online(2));
|
||||
// Only 45 bits count.
|
||||
assert_eq!(online(1 << 45), online(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_codes_round_trip() {
|
||||
for (ip, port, random) in [([192, 168, 1, 20], 47_900, 0x0ABC_DEF0_1234_u64), ([10, 0, 0, 1], 1, 0), ([127, 0, 0, 1], 65_535, u64::MAX)] {
|
||||
let local = Local::new(Ipv4Addr::from(ip), port, random);
|
||||
let code = local.code();
|
||||
assert_eq!(code.len(), LOCAL_LEN);
|
||||
let shown = grouped(&code);
|
||||
assert_eq!(shown.len(), LOCAL_LEN + 3, "four groups of five: {shown}");
|
||||
assert_eq!(parse(&shown), Ok(Code::Local(local)));
|
||||
assert_eq!(parse(&shown.to_lowercase().replace('-', " ")), Ok(Code::Local(local)));
|
||||
// The link a joiner connects with names the same session, and gives the code back.
|
||||
let link = local.link();
|
||||
assert_eq!(link, format!("figcraft://join?host={}:{port}&file=local&token={}", Ipv4Addr::from(ip), local.token()));
|
||||
assert_eq!(local.token().len(), 17);
|
||||
assert_eq!(Local::from_link(&link), Some(local));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_local_codes_are_caught() {
|
||||
let code = Local::new(Ipv4Addr::new(192, 168, 1, 20), 47_900, 0x123_4567_89AB).code();
|
||||
let symbols = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
let (mut tried, mut missed) = (0, 0);
|
||||
for at in 0..code.len() {
|
||||
for s in symbols.chars().filter(|s| code.chars().nth(at) != Some(*s)) {
|
||||
let mut changed: Vec<char> = code.chars().collect();
|
||||
changed[at] = s;
|
||||
tried += 1;
|
||||
missed += usize::from(parse(&changed.iter().collect::<String>()).is_ok());
|
||||
}
|
||||
}
|
||||
// Eight check bits: about one changed symbol in 256 slips through (and then fails to join).
|
||||
assert!(missed * 50 < tried, "{missed} of {tried} single-symbol changes went unnoticed");
|
||||
// Two symbols swapped.
|
||||
let mut swapped: Vec<char> = code.chars().collect();
|
||||
let at = (0..19).find(|i| swapped[*i] != swapped[*i + 1]).unwrap();
|
||||
swapped.swap(at, at + 1);
|
||||
assert!(parse(&swapped.iter().collect::<String>()).unwrap_err().contains("typing mistake"));
|
||||
// Wrong lengths say what a code looks like.
|
||||
assert!(parse(&code[..19]).unwrap_err().contains("9 characters"));
|
||||
assert!(parse("K7M2QX9T").unwrap_err().contains("that one has 8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_local_session_links_have_a_code() {
|
||||
for link in [
|
||||
"figcraft://join?host=192.168.1.20:47900&file=abc&token=local-0123456789a",
|
||||
"figcraft://join?host=192.168.1.20:47900&file=local&token=AbCdEfGhIjKlMnOpQrStUv",
|
||||
"figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a",
|
||||
"figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a",
|
||||
"figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az",
|
||||
"figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a",
|
||||
"ws://192.168.1.20:47900/?file=local&token=local-0123456789a",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(Local::from_link(link), None, "{link}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_messages_go_last_on_the_wire() {
|
||||
use crate::message::{Admin, AdminReply, ClientMsg, Resolve, ServerMsg, encode};
|
||||
// postcard writes a variant's index: an older peer reads everything it knew unchanged, and
|
||||
// fails only on the variants added for join codes (which is how the app tells).
|
||||
let tag = |bytes: Vec<u8>| bytes[1];
|
||||
let ask = Resolve { protocol: 2, client_version: String::new(), code: "K7M2QX9TD".into() };
|
||||
assert_eq!(tag(encode(&ClientMsg::Ping).unwrap()), 4);
|
||||
assert_eq!(tag(encode(&ClientMsg::Report { reason: String::new() }).unwrap()), 13);
|
||||
assert_eq!(tag(encode(&ClientMsg::Resolve(ask)).unwrap()), 14);
|
||||
assert_eq!(tag(encode(&ServerMsg::Pong).unwrap()), 7);
|
||||
assert_eq!(tag(encode(&ServerMsg::Reported { error: None }).unwrap()), 13);
|
||||
assert_eq!(tag(encode(&ServerMsg::Resolved { file: String::new(), token: String::new() }).unwrap()), 14);
|
||||
assert_eq!(tag(encode(&Admin::Blocked).unwrap()), 11);
|
||||
assert_eq!(tag(encode(&Admin::Code { file: String::new(), token: String::new() }).unwrap()), 12);
|
||||
assert_eq!(tag(encode(&AdminReply::Blocked(Vec::new())).unwrap()), 10);
|
||||
assert_eq!(tag(encode(&AdminReply::Code { code: String::new() }).unwrap()), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_never_panics() {
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15_u64;
|
||||
for _ in 0..20_000 {
|
||||
let len = (seed % 40) as usize;
|
||||
let text: String = (0..len)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
char::from_u32((seed >> 40) as u32 % 0x250).unwrap_or('-')
|
||||
})
|
||||
.collect();
|
||||
let _ = parse(&text);
|
||||
let _ = Local::from_link(&text);
|
||||
let _ = grouped(&text);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
pub mod change;
|
||||
pub mod client;
|
||||
pub mod code;
|
||||
mod diff;
|
||||
mod docprops;
|
||||
pub mod frac;
|
||||
|
||||
@@ -266,6 +266,10 @@ pub enum Admin {
|
||||
Unblock { ip: String },
|
||||
/// The blocked addresses.
|
||||
Blocked,
|
||||
/// A join code for the link of `file` whose token this is (the token proves the link and lets
|
||||
/// the server give it to whoever types the code): [`AdminReply::Code`]. A link has one code:
|
||||
/// asking again replaces it. Servers older than join codes can't read this request.
|
||||
Code { file: String, token: String },
|
||||
}
|
||||
|
||||
/// A file a server keeps.
|
||||
@@ -336,6 +340,10 @@ pub enum AdminReply {
|
||||
Listing(Vec<FileRow>),
|
||||
Reports(Vec<ReportInfo>),
|
||||
Blocked(Vec<String>),
|
||||
/// A link's new join code (shown this once; the server keeps only a hash).
|
||||
Code {
|
||||
code: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A file with what its operator moderates by.
|
||||
@@ -380,6 +388,17 @@ pub struct OpenHosting {
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
/// Asks a server which file a join code opens (the first message on a connection, instead of
|
||||
/// [`Hello`]). Answered with [`ServerMsg::Resolved`], then the connection closes; a wrong code, too
|
||||
/// many wrong ones from an address, or a server that hosts no files is `Refused`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Resolve {
|
||||
pub protocol: u32,
|
||||
pub client_version: String,
|
||||
/// The code's symbols ([`crate::code::normalize`]d).
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// What a server says about its hosting: whether anyone may publish, who runs it and its terms,
|
||||
/// how long files published without the key are kept unopened, and the largest file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -580,6 +599,8 @@ pub enum ClientMsg {
|
||||
Report {
|
||||
reason: String,
|
||||
},
|
||||
/// Ask which file a join code opens (the first message on a connection, instead of `Hello`).
|
||||
Resolve(Resolve),
|
||||
}
|
||||
|
||||
/// Server → client.
|
||||
@@ -642,6 +663,12 @@ pub enum ServerMsg {
|
||||
Reported {
|
||||
error: Option<String>,
|
||||
},
|
||||
/// The answer to [`ClientMsg::Resolve`]: the file the code opens and its link's token (what a
|
||||
/// `Hello` joins with).
|
||||
Resolved {
|
||||
file: String,
|
||||
token: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
|
||||
|
||||
@@ -392,6 +392,7 @@ impl Server {
|
||||
| ClientMsg::OpenPlugins(_)
|
||||
| ClientMsg::Plugins(_)
|
||||
| ClientMsg::OpenHosting(_)
|
||||
| ClientMsg::Resolve(_)
|
||||
| ClientMsg::Report { .. } => {
|
||||
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })]
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ fn publish_to_server(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let link = match server {
|
||||
// The server's address with its key: the file is kept there first.
|
||||
Some(server) if key.is_some() || (server.contains("key=") && !server.contains("token=")) => {
|
||||
let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone() };
|
||||
let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone(), codes: false };
|
||||
let file = match s.net(NetRequest::Upload(opts))?.0 {
|
||||
NetReply::Upload(r) => r.map_err(|e| bad(cmd, e))?,
|
||||
_ => return Err(EngineError::Other("the server answered something else".into())),
|
||||
|
||||
@@ -292,12 +292,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!(
|
||||
"pref.publishServer",
|
||||
"Server to publish to",
|
||||
"{url: ws://host:port/ or wss://host/path/ (no key; empty: none)} → {url}",
|
||||
"Server for online sharing",
|
||||
"{url?: ws://host:port/ or wss://host/path/ (no key; empty: online sharing off; omit: back to the built-in server)} → {url, default (the built-in server)}: where Share ▸ Online keeps files and where join codes are asked",
|
||||
false,
|
||||
|s, p| {
|
||||
const CMD: &str = "pref.publishServer";
|
||||
let url = req_string(CMD, p, "url")?.trim().to_string();
|
||||
let url = match get(p, "url") {
|
||||
None => crate::live::DEFAULT_SERVER.to_string(),
|
||||
Some(_) => req_string(CMD, p, "url")?.trim().to_string(),
|
||||
};
|
||||
let web_socket = url.starts_with("ws://") || url.starts_with("wss://");
|
||||
if !url.is_empty()
|
||||
&& (!web_socket || url.len() > 500 || url.contains(|c: char| c.is_whitespace() || c.is_control() || c == '?' || c == '#'))
|
||||
@@ -308,7 +311,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
));
|
||||
}
|
||||
s.prefs.publish_server = url;
|
||||
Ok(json!({"url": s.prefs.publish_server}))
|
||||
Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER}))
|
||||
}
|
||||
),
|
||||
spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| {
|
||||
|
||||
@@ -564,9 +564,9 @@ pub struct Prefs {
|
||||
/// The plugin directory browsed last (`ws://host:port/`, never with a key).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_directory: Option<String>,
|
||||
/// The server Share ▸ Publish to server suggests (`wss://host/`, never with a key; empty:
|
||||
/// none). There is no built-in default.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
/// The server files are shared through online (Share ▸ Online, join codes; Publish to server
|
||||
/// suggests it too): `wss://host/`, never with a key. [`live::DEFAULT_SERVER`] unless the user
|
||||
/// chose another; empty: online sharing is off (saved as empty, so it stays off).
|
||||
pub publish_server: String,
|
||||
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
|
||||
/// [`cmd::file::MAX_RECENT`]).
|
||||
@@ -591,7 +591,7 @@ impl Default for Prefs {
|
||||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
plugin_directory: None,
|
||||
publish_server: String::new(),
|
||||
publish_server: live::DEFAULT_SERVER.into(),
|
||||
recent_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1353,6 +1353,8 @@ mod live_access_tests;
|
||||
#[cfg(test)]
|
||||
mod live_docs_tests;
|
||||
#[cfg(test)]
|
||||
mod live_share_tests;
|
||||
#[cfg(test)]
|
||||
mod live_signal_tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
|
||||
+209
-16
@@ -28,6 +28,11 @@
|
||||
//! closing a document ends its session only. One link carries them all, each under its
|
||||
//! [`SessionId`].
|
||||
//!
|
||||
//! Sharing with a join code (`collab.share`): online, the open document is published to the
|
||||
//! server in Preferences and the server names its links with short codes; on the local network
|
||||
//! the app hosts and the code carries the host's address and the session's secret
|
||||
//! ([`figcraft_collab::code`]). `collab.join` takes a code of either kind, or an invite link.
|
||||
//!
|
||||
//! Cursor chat (`collab.cursorChat`) and spotlights (`collab.spotlight`) travel as signals:
|
||||
//! short-lived presence the host passes on to everyone, never stored. A spotlight makes everyone
|
||||
//! follow the person who started it; moving your own view stops following, as with any follow.
|
||||
@@ -37,6 +42,7 @@ use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::code::{self, Code};
|
||||
pub use figcraft_collab::message::Signal;
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, EditingText, LibraryReply, LibraryReq, PluginReply, PluginReq, ServerMsg};
|
||||
|
||||
@@ -53,6 +59,10 @@ use crate::params::{get, num, req_string, string};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{DocState, EngineError, Result, Session, TextEdit, ViewState, bad, spec};
|
||||
|
||||
/// The server files are shared through online unless Preferences name another: the only place
|
||||
/// its address is written.
|
||||
pub const DEFAULT_SERVER: &str = "wss://collab.craftapps.dev/";
|
||||
|
||||
/// Most selected ids one presence update carries (the rest aren't shown to others).
|
||||
pub const MAX_SELECTION: usize = 10_000;
|
||||
/// Longest name shown to others (characters).
|
||||
@@ -175,6 +185,56 @@ pub struct Published {
|
||||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
/// How a file is shared with a join code.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Via {
|
||||
/// Through a server: the file is kept there and the server hands out the codes.
|
||||
Online,
|
||||
/// From this app, to people on the same network.
|
||||
Local,
|
||||
}
|
||||
|
||||
/// A file shared with a join code (`collab.share`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Share {
|
||||
pub via: Via,
|
||||
/// Online: the server's address (without a key).
|
||||
pub server: String,
|
||||
/// What the code shown lets people do.
|
||||
pub access: Access,
|
||||
/// The join codes (their symbols) that let people edit and only view. `None`: not there yet,
|
||||
/// or there is none (a server from before join codes, a host without an IPv4 address; on the
|
||||
/// local network everyone edits): the invite links are shared instead.
|
||||
pub edit: Option<String>,
|
||||
pub view: Option<String>,
|
||||
}
|
||||
|
||||
impl Share {
|
||||
/// The code to show: the one for [`access`](Self::access), grouped (`K7M-2QX-9TD`).
|
||||
pub fn code(&self) -> Option<String> {
|
||||
let code = if self.access == Access::View { &self.view } else { &self.edit };
|
||||
code.as_deref().map(code::grouped)
|
||||
}
|
||||
}
|
||||
|
||||
/// What someone typed to join, as what the link joins: an invite link as it is, a local network
|
||||
/// code as the link it stands for, an online code with `server` to ask about it.
|
||||
pub fn join_target(server: &str, typed: &str) -> std::result::Result<String, String> {
|
||||
let typed = typed.trim();
|
||||
if typed.contains("://") || typed.contains('?') {
|
||||
return Ok(typed.to_string());
|
||||
}
|
||||
match code::parse(typed)? {
|
||||
Code::Local(local) => Ok(local.link()),
|
||||
Code::Online(_) if server.trim().is_empty() => Err(format!("that is an online join code, and {ONLINE_OFF}")),
|
||||
Code::Online(code) => Ok(format!("figcraft://join?server={}&code={code}", server.trim())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why nothing can be shared or joined online.
|
||||
pub const ONLINE_OFF: &str = "online sharing is off: choose a server in Preferences (or share on the local network)";
|
||||
|
||||
/// A session's number on the link: the engine picks it (from 1) when it starts the session.
|
||||
pub type SessionId = u64;
|
||||
/// The [`SessionId`] of events that aren't about a session (answers to background requests).
|
||||
@@ -203,6 +263,12 @@ pub enum Event {
|
||||
},
|
||||
/// The document is on the server (`collab.publish`); joining it follows.
|
||||
Published(Published),
|
||||
/// The server's join codes for the published file's edit link and view link (`collab.share`;
|
||||
/// a server from before join codes sends none).
|
||||
Codes {
|
||||
edit: String,
|
||||
view: String,
|
||||
},
|
||||
/// What the server says about its hosting (operator, terms, this file's expiry).
|
||||
Hosting(HostInfo),
|
||||
/// The server's answer to `collab.report`: kept, or why not.
|
||||
@@ -306,6 +372,8 @@ pub struct HostOptions {
|
||||
pub name: String,
|
||||
/// The shared file (the session's authoritative copy starts from it).
|
||||
pub doc: Arc<Document>,
|
||||
/// Shared with a join code (`collab.share`): the session's secret is short enough to type.
|
||||
pub short: bool,
|
||||
}
|
||||
|
||||
/// What [`LiveLink::publish`] needs to put the open file on a server.
|
||||
@@ -322,6 +390,8 @@ pub struct PublishOptions {
|
||||
/// Your name in the session (empty: the server picks one).
|
||||
pub me: String,
|
||||
pub doc: Arc<Document>,
|
||||
/// Ask the server for join codes for the file's links ([`Event::Codes`]; `collab.share`).
|
||||
pub codes: bool,
|
||||
}
|
||||
|
||||
/// The networking behind live sessions, installed by the app ([`Lives::install`]). It carries
|
||||
@@ -444,6 +514,8 @@ pub struct Live {
|
||||
hold: u32,
|
||||
/// The file you published to a server in this session, with its links.
|
||||
pub published: Option<Published>,
|
||||
/// The session was started by sharing with a join code (`collab.share`): how, and its codes.
|
||||
pub share: Option<Share>,
|
||||
/// What the server of the file you're in says about its hosting (operator, terms, expiry);
|
||||
/// a server that says it takes reports (`collab.report`).
|
||||
pub hosting: Option<HostInfo>,
|
||||
@@ -856,9 +928,19 @@ impl Session {
|
||||
self.live.end(id);
|
||||
}
|
||||
Event::Published(p) => {
|
||||
self.message = Some(format!("Published {} to the server", p.name));
|
||||
// Shared with a code: the popover shows it, nothing more to say.
|
||||
if live.share.is_none() {
|
||||
self.message = Some(format!("Published {} to the server", p.name));
|
||||
}
|
||||
live.published = Some(p);
|
||||
}
|
||||
Event::Codes { edit, view } => {
|
||||
// Only what a server made: nine symbols each, or nothing.
|
||||
let online = |c: String| code::normalize(&c).ok().filter(|c| c.len() == code::ONLINE_LEN);
|
||||
if let Some(share) = live.share.as_mut() {
|
||||
(share.edit, share.view) = (online(edit), online(view));
|
||||
}
|
||||
}
|
||||
Event::Hosting(info) => live.hosting = Some(clean_hosting(info)),
|
||||
Event::Reported(error) => {
|
||||
self.message =
|
||||
@@ -1343,6 +1425,13 @@ impl Session {
|
||||
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
|
||||
"readOnly": l.doc_uid.is_some_and(|u| self.live_read_only(u).is_some()),
|
||||
"published": l.published,
|
||||
"code": l.share.as_ref().and_then(Share::code),
|
||||
"share": l.share.as_ref().map(|sh| json!({
|
||||
"via": sh.via,
|
||||
"access": sh.access.id(),
|
||||
"editCode": sh.edit.as_deref().map(code::grouped),
|
||||
"viewCode": sh.view.as_deref().map(code::grouped),
|
||||
})),
|
||||
"hosting": l.hosting.as_ref().map(hosting_json),
|
||||
"follow": l.follow,
|
||||
"signals": l.signals,
|
||||
@@ -1391,34 +1480,98 @@ fn unavailable(cmd: &str) -> EngineError {
|
||||
}
|
||||
|
||||
fn host(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.host";
|
||||
start_host(s, "collab.host", p, Bind::Local, false)?;
|
||||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
/// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with
|
||||
/// a secret a join code can carry): the new session.
|
||||
fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result<SessionId> {
|
||||
let port = match get(p, "port") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(CMD, "`port` must be a whole number from 0 to 65535"))?),
|
||||
Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(cmd, "`port` must be a whole number from 0 to 65535"))?),
|
||||
};
|
||||
let bind = match string(p, "bind").as_deref().map(str::trim) {
|
||||
None | Some("local") => Bind::Local,
|
||||
None => bind,
|
||||
Some("local") => Bind::Local,
|
||||
Some("lan") => Bind::Lan,
|
||||
Some(other) => other.parse::<IpAddr>().map(Bind::Addr).map_err(|_| bad(CMD, "`bind` must be \"local\", \"lan\" or an IP address"))?,
|
||||
Some(other) => other.parse::<IpAddr>().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?,
|
||||
};
|
||||
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
|
||||
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc };
|
||||
let (id, info) = s.live.start(CMD, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?;
|
||||
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short };
|
||||
let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?;
|
||||
s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
|
||||
Ok(s.live_summary())
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn publish(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.publish";
|
||||
let server = req_string(CMD, p, "server")?;
|
||||
start_publish(s, CMD, &server, p, false)?;
|
||||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
/// Start publishing the open file to `server` for command `cmd` (`codes`: asking for join codes
|
||||
/// too): the new session.
|
||||
fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, codes: bool) -> Result<SessionId> {
|
||||
let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
|
||||
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
|
||||
let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::<String>()).filter(|n| !n.is_empty());
|
||||
let opts =
|
||||
PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me: display_name(&s.prefs), doc };
|
||||
let (id, ()) = s.live.start(CMD, |l, id| l.publish(id, opts).map_err(|e| bad(CMD, e)))?;
|
||||
let me = display_name(&s.prefs);
|
||||
let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, codes };
|
||||
let (id, ()) = s.live.start(cmd, |l, id| l.publish(id, opts).map_err(|e| bad(cmd, e)))?;
|
||||
// This tab becomes the server file's synced document once it is there.
|
||||
s.live.open(Live { id, status: Status::Connecting, doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Why a local network share has no view-only code.
|
||||
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only code";
|
||||
|
||||
fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.share";
|
||||
let via = match string(p, "via").as_deref().map(str::trim) {
|
||||
None | Some("online") => Via::Online,
|
||||
Some("local") => Via::Local,
|
||||
Some(_) => return Err(bad(CMD, "`via` must be \"online\" or \"local\"")),
|
||||
};
|
||||
let access = match string(p, "access") {
|
||||
None => None,
|
||||
Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?),
|
||||
};
|
||||
// Already shared: only which code is shown changes.
|
||||
if let Some(shared) = s.live().share.as_ref().filter(|_| s.live().status.active()).map(|sh| sh.via) {
|
||||
if get(p, "via").is_some() && shared != via {
|
||||
return Err(bad(CMD, "this file is shared the other way already: stop sharing it first (collab.stop)"));
|
||||
}
|
||||
if access == Some(Access::View) && shared == Via::Local {
|
||||
return Err(bad(CMD, LOCAL_EDITS));
|
||||
}
|
||||
if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) {
|
||||
sh.access = a;
|
||||
}
|
||||
return Ok(s.live_summary());
|
||||
}
|
||||
let access = access.unwrap_or_default();
|
||||
let mut server = String::new();
|
||||
let id = match via {
|
||||
Via::Local if access == Access::View => return Err(bad(CMD, LOCAL_EDITS)),
|
||||
Via::Local => start_host(s, CMD, p, Bind::Lan, true)?,
|
||||
Via::Online => {
|
||||
server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string();
|
||||
if server.is_empty() {
|
||||
return Err(EngineError::Disabled(CMD.into(), ONLINE_OFF.into()));
|
||||
}
|
||||
start_publish(s, CMD, &server, p, true)?
|
||||
}
|
||||
};
|
||||
if let Some(live) = s.live.sessions.get_mut(&id) {
|
||||
// On the local network the code says where the session is and its secret, as its link does.
|
||||
let edit = live.info.as_ref().and_then(|i| code::Local::from_link(&i.link)).map(|l| l.code());
|
||||
// Kept without a key the address may carry: stopping asks the server as the file's owner.
|
||||
let server = server.split('?').next().unwrap_or_default().to_string();
|
||||
live.share = Some(Share { via, server, access, edit, view: None });
|
||||
}
|
||||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
@@ -1495,9 +1648,13 @@ pub const MAX_REPORT: usize = 500;
|
||||
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.join";
|
||||
let link = req_string(CMD, p, "link")?;
|
||||
let typed = match string(p, "code") {
|
||||
Some(code) => code,
|
||||
None => req_string(CMD, p, "link").map_err(|_| bad(CMD, "give the join `code` you were told, or the invite `link`"))?,
|
||||
};
|
||||
let link = join_target(&s.prefs.publish_server, &typed).map_err(|e| bad(CMD, e))?;
|
||||
let (name, tab) = (display_name(&s.prefs), s.tab());
|
||||
let (id, ()) = s.live.start(CMD, |l, id| l.join(id, link.trim(), &name).map_err(|e| bad(CMD, e)))?;
|
||||
let (id, ()) = s.live.start(CMD, |l, id| l.join(id, &link, &name).map_err(|e| bad(CMD, e)))?;
|
||||
// The session's file opens in a tab of its own; until then the join shows on this one.
|
||||
s.live.open(Live { id, status: Status::Connecting, tab, ..Live::default() });
|
||||
Ok(s.live.get(id).map_or(Value::Null, |l| s.summary(l)))
|
||||
@@ -1511,6 +1668,29 @@ fn leave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"ended": ended.is_some_and(|l| l.status.active())}))
|
||||
}
|
||||
|
||||
fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A file shared online was put on the server for this: it goes with the sharing (its codes
|
||||
// and links stop working; everyone keeps the copy they have).
|
||||
let live = s.live();
|
||||
let remove = match (&live.share, &live.published) {
|
||||
(Some(Share { via: Via::Online, server, .. }), Some(Published { file, owner: Some(owner), .. })) => Some(NetRequest::Hosting {
|
||||
server: server.clone(),
|
||||
file: file.clone(),
|
||||
owner: owner.clone(),
|
||||
req: Some(Admin::Remove { file: file.clone() }),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let out = leave(s, p)?;
|
||||
// Nobody waits for the answer (job 0 is no command's): a file that stays expires on its own.
|
||||
if let (Some(req), Some(link)) = (remove, s.live.link())
|
||||
&& !link.background(0, req.clone())
|
||||
{
|
||||
let _ = link.request(req);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn follow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match get(p, "client") {
|
||||
None => {
|
||||
@@ -1568,11 +1748,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
host
|
||||
),
|
||||
spec!("collab.stop", "Stop live session", "{} (the open file's session; also leaves a joined session) → {ended}", false, leave),
|
||||
spec!(
|
||||
"collab.share",
|
||||
"Share with a join code",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` has both (`editCode`, `viewCode`); `code` stays null when the server is older than join codes (share `published.edit` / `published.view` instead). Called again while shared: only switches which code `code` is (`access`). `collab.stop` ends it",
|
||||
false,
|
||||
share
|
||||
),
|
||||
spec!(
|
||||
"collab.stop",
|
||||
"Stop live session",
|
||||
"{} (the open file's session; also leaves a joined session; a file shared online with `collab.share` is removed from the server, so its codes and links stop working: `collab.leave` keeps it there) → {ended}",
|
||||
false,
|
||||
stop
|
||||
),
|
||||
spec!(
|
||||
"collab.join",
|
||||
"Join live session",
|
||||
"{link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)",
|
||||
"{code: a join code (K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1) | link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link; a code here works too)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)",
|
||||
false,
|
||||
join
|
||||
),
|
||||
@@ -1614,7 +1807,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.peers",
|
||||
"People in this file",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, editCode, viewCode} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
false,
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Sharing with a join code against a scripted link: `collab.share` online and on the local
|
||||
//! network, the codes in `collab.peers`, switching what the code lets people do, stopping, and
|
||||
//! `collab.join` with a code of either kind or a link (what typing tolerates, and what it says
|
||||
//! when it can't be a code).
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::code::{self, Local};
|
||||
use figcraft_collab::message::{Admin, ClientMsg};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::live::{
|
||||
Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, NetRequest, Presence, PublishOptions, Published, Role, SessionInfo, Status,
|
||||
join_target,
|
||||
};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// What the link was asked to do, and the events it hands out next (as the session started last).
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
hosted: Vec<HostOptions>,
|
||||
published: Vec<PublishOptions>,
|
||||
joined: Vec<String>,
|
||||
asked: Vec<(u64, NetRequest)>,
|
||||
left: Vec<u64>,
|
||||
events: Vec<Event>,
|
||||
session: u64,
|
||||
}
|
||||
|
||||
struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
/// Where the scripted host listens, and its session's secret.
|
||||
const HOST: Local = Local { ip: Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
// As the network link does: a short secret when the session is shared with a code.
|
||||
let link = if opts.short { HOST.link() } else { "figcraft://join?host=192.168.1.20:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv".to_string() };
|
||||
s.hosted.push(opts);
|
||||
Ok(SessionInfo {
|
||||
role: Role::Host,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Shared".into(),
|
||||
pages: vec![],
|
||||
url: link.replace("figcraft://join?host=", "ws://"),
|
||||
link,
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
})
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
if !link.starts_with("figcraft://join?") {
|
||||
return Err("that's not a live session invite".into());
|
||||
}
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push(link.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, session: u64, opts: PublishOptions) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn background(&mut self, job: u64, req: NetRequest) -> bool {
|
||||
self.0.lock().unwrap().asked.push((job, req));
|
||||
true
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().left.push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let session = s.session;
|
||||
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
let mut s = Session::new();
|
||||
let script = Arc::new(Mutex::new(Script::default()));
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
(s, script)
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn err(s: &mut Session, cmd: &str, p: Value) -> String {
|
||||
s.execute(cmd, &p).unwrap_err().to_string()
|
||||
}
|
||||
|
||||
/// The file is on the server and joined: its links, then (when given) its codes.
|
||||
fn on_the_server(s: &mut Session, script: &Arc<Mutex<Script>>, codes: Option<(&str, &str)>) {
|
||||
let link = |token: &str| format!("figcraft://join?server=wss://share.example:443/&file=f1&token={token}");
|
||||
let published = Published {
|
||||
server: "wss://share.example:443/".into(),
|
||||
file: "f1".into(),
|
||||
name: "Untitled".into(),
|
||||
edit: link("EditEditEditEditEdit00"),
|
||||
view: link("ViewViewViewViewView00"),
|
||||
owner: Some("OwnerOwnerOwnerOwner00".into()),
|
||||
expires: None,
|
||||
};
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Untitled".into(),
|
||||
pages: vec![],
|
||||
link: published.edit.clone(),
|
||||
url: String::new(),
|
||||
address: "wss://share.example:443/".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
};
|
||||
let mut events = vec![Event::Published(published)];
|
||||
events.extend(codes.map(|(edit, view)| Event::Codes { edit: edit.into(), view: view.into() }));
|
||||
events.push(Event::Welcome { info, peers: vec![] });
|
||||
script.lock().unwrap().events.extend(events);
|
||||
s.live_poll();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() {
|
||||
let (mut s, _) = session();
|
||||
assert_eq!(s.prefs.publish_server, DEFAULT_SERVER);
|
||||
assert!(DEFAULT_SERVER.starts_with("wss://") && DEFAULT_SERVER.ends_with('/'), "TLS, and an address the preference itself accepts");
|
||||
assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": DEFAULT_SERVER}))["url"], DEFAULT_SERVER);
|
||||
// Another server; back to the built-in one; off.
|
||||
assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}))["url"], "wss://share.example/");
|
||||
let reset = ex(&mut s, "pref.publishServer", json!({}));
|
||||
assert_eq!((&reset["url"], &reset["default"]), (&json!(DEFAULT_SERVER), &json!(DEFAULT_SERVER)));
|
||||
assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": ""}))["url"], "");
|
||||
// Off is saved as off (an empty address isn't left out, or the default would come back).
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(saved["publish_server"], "");
|
||||
let loaded: crate::Prefs = serde_json::from_value(saved).unwrap();
|
||||
assert!(loaded.publish_server.is_empty());
|
||||
// Preferences saved before there was a default get it.
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert_eq!(old.publish_server, DEFAULT_SERVER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}));
|
||||
let r = ex(&mut s, "collab.share", json!({}));
|
||||
assert_eq!(
|
||||
(&r["status"], &r["share"]["via"], &r["share"]["access"], &r["code"]),
|
||||
(&json!("connecting"), &json!("online"), &json!("edit"), &Value::Null)
|
||||
);
|
||||
assert_eq!(r["readOnly"], true, "as for any publish, until the file is there");
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) };
|
||||
assert_eq!((p.server.as_str(), p.key.as_deref(), p.name.as_str(), p.codes), ("wss://share.example/", None, "Untitled", true));
|
||||
assert!(sc.hosted.is_empty());
|
||||
}
|
||||
on_the_server(&mut s, &script, Some(("K7M2QX9TD", "4FHJ8R2WN")));
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["code"]), (&json!("live"), &json!("K7M-2QX-9TD")));
|
||||
assert_eq!(r["share"], json!({"via": "online", "access": "edit", "editCode": "K7M-2QX-9TD", "viewCode": "4FH-J8R-2WN"}));
|
||||
assert!(s.message.is_none(), "the popover shows the code: no toast about publishing");
|
||||
// Asking again only switches which code is the one to give out.
|
||||
let r = ex(&mut s, "collab.share", json!({"access": "view"}));
|
||||
assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view")));
|
||||
assert_eq!(ex(&mut s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts");
|
||||
assert_eq!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD");
|
||||
assert_eq!(script.lock().unwrap().published.len(), 1);
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
|
||||
// Stopping: the session ends and the server is asked, as the file's owner, to remove it.
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!(sc.left, vec![1]);
|
||||
let [(0, NetRequest::Hosting { server, file, owner, req: Some(Admin::Remove { file: removed }) })] = sc.asked.as_slice() else {
|
||||
panic!("asked {:?}", sc.asked)
|
||||
};
|
||||
assert_eq!((server.as_str(), file.as_str(), owner.as_str(), removed.as_str()), ("wss://share.example/", "f1", "OwnerOwnerOwnerOwner00", "f1"));
|
||||
drop(sc);
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_without_join_codes_leaves_the_links() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "collab.share", json!({"via": "online", "server": "wss://old.example/?key=KeyKeyKeyKeyKeyKeyKey0", "name": "Poster"}));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!((sc.published[0].server.as_str(), sc.published[0].name.as_str()), ("wss://old.example/?key=KeyKeyKeyKeyKeyKeyKey0", "Poster"));
|
||||
}
|
||||
on_the_server(&mut s, &script, None);
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["code"], &r["share"]["editCode"]), (&json!("live"), &Value::Null, &Value::Null));
|
||||
assert!(r["published"]["edit"].as_str().unwrap().contains("token="), "the links are what there is to share");
|
||||
assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary");
|
||||
// Codes that aren't codes are not shown either.
|
||||
script.lock().unwrap().events.push(Event::Codes { edit: "<script>".into(), view: "K7M2QX9TDX".into() });
|
||||
s.live_poll();
|
||||
assert_eq!(ex(&mut s, "collab.peers", json!({}))["share"]["viewCode"], Value::Null);
|
||||
// `collab.leave` keeps the file on the server: nothing is asked of it.
|
||||
ex(&mut s, "collab.leave", json!({}));
|
||||
assert!(script.lock().unwrap().asked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_sharing_is_off_without_a_server() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "pref.publishServer", json!({"url": ""}));
|
||||
assert!(matches!(s.execute("collab.share", &json!({})), Err(EngineError::Disabled(_, why)) if why.contains("online sharing is off")));
|
||||
assert!(err(&mut s, "collab.join", json!({"code": "K7M-2QX-9TD"})).contains("online sharing is off"));
|
||||
// The local network needs no server, and a server named for one share is enough.
|
||||
ex(&mut s, "collab.share", json!({"server": "wss://share.example/"}));
|
||||
assert_eq!(script.lock().unwrap().published[0].server, "wss://share.example/");
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
assert_eq!(ex(&mut s, "collab.share", json!({"via": "local"}))["status"], "live");
|
||||
// Without a link (the one-shot CLI) there is nothing to share with.
|
||||
let mut bare = Session::new();
|
||||
assert!(matches!(bare.execute("collab.share", &json!({"via": "local"})), Err(EngineError::Disabled(..))));
|
||||
assert!(matches!(bare.execute("collab.join", &json!({"code": HOST.code()})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
|
||||
let (mut s, script) = session();
|
||||
let r = ex(&mut s, "collab.share", json!({"via": "local"}));
|
||||
assert_eq!((&r["status"], &r["role"], &r["share"]["via"]), (&json!("live"), &json!("host"), &json!("local")));
|
||||
let shown = r["code"].as_str().unwrap();
|
||||
assert_eq!(code::parse(shown), Ok(code::Code::Local(HOST)), "{shown}");
|
||||
assert_eq!(shown, code::grouped(&HOST.code()));
|
||||
assert_eq!((&r["link"], &r["share"]["viewCode"]), (&json!(HOST.link()), &Value::Null));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [h] = sc.hosted.as_slice() else { panic!("hosted {}", sc.hosted.len()) };
|
||||
assert!(h.short && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
|
||||
}
|
||||
// Everyone edits there.
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "view"})).contains("share it online"));
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
|
||||
assert!(script.lock().unwrap().asked.is_empty(), "no server to tell");
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "view"})).contains("share it online"));
|
||||
// Where to listen is the advanced part.
|
||||
ex(&mut s, "collab.share", json!({"via": "local", "bind": "local", "port": 5000}));
|
||||
let sc = script.lock().unwrap();
|
||||
assert!(sc.hosted[1].bind == Bind::Local && sc.hosted[1].port == Some(5000));
|
||||
drop(sc);
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
for bad in [json!({"via": "post"}), json!({"via": "local", "port": 70000}), json!({"via": "local", "bind": "everywhere"})] {
|
||||
assert!(matches!(s.execute("collab.share", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
// A plain `collab.host` session has an invite link, not a code.
|
||||
let r = ex(&mut s, "collab.host", json!({}));
|
||||
assert_eq!((&r["code"], &r["share"]), (&Value::Null, &Value::Null));
|
||||
assert!(!script.lock().unwrap().hosted[2].short);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_takes_a_code_of_either_kind_or_a_link() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}));
|
||||
let asks = "figcraft://join?server=wss://share.example/&code=K7M2QX9TD";
|
||||
let local = HOST.link();
|
||||
let link = "figcraft://join?host=10.0.0.5:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
let shown = code::grouped(&HOST.code());
|
||||
let table: [(Value, &str); 8] = [
|
||||
(json!({"code": "K7M-2QX-9TD"}), asks),
|
||||
(json!({"code": " k7m 2qx 9td \n"}), asks),
|
||||
(json!({"code": "k7m2qx9td"}), asks),
|
||||
(json!({"link": "K7M2QX9TD"}), asks),
|
||||
(json!({"code": shown}), &local),
|
||||
(json!({"code": shown.to_lowercase().replace('-', "")}), &local),
|
||||
(json!({"link": link}), link),
|
||||
// The one field takes either: a link where a code is expected.
|
||||
(json!({"code": format!(" {link} ")}), link),
|
||||
];
|
||||
for (params, want) in table {
|
||||
let r = ex(&mut s, "collab.join", params.clone());
|
||||
assert_eq!(r["status"], "connecting", "{params}");
|
||||
assert_eq!(script.lock().unwrap().joined.last().map(String::as_str), Some(want), "{params}");
|
||||
}
|
||||
// O for 0 and I or L for 1 are the same code.
|
||||
assert_eq!(join_target("wss://s.example/", "O1l-iLo-0oO"), join_target("wss://s.example/", "011-110-000"));
|
||||
// What can't be a code says why, and nothing is asked of the network.
|
||||
let before = script.lock().unwrap().joined.len();
|
||||
for (typed, why) in [
|
||||
("", "type the join code"),
|
||||
("K7M-2QX", "9 characters"),
|
||||
("K7M-2QX-9TDX", "that one has 10"),
|
||||
("K7M-2QU-9TD", "no U"),
|
||||
("hello world!", "only letters and digits"),
|
||||
(&shown.replace(&shown[..1], if shown.starts_with('Z') { "Y" } else { "Z" }), "typing mistake"),
|
||||
] {
|
||||
let e = err(&mut s, "collab.join", json!({"code": typed}));
|
||||
assert!(e.contains(why), "{typed:?}: {e}");
|
||||
}
|
||||
assert!(err(&mut s, "collab.join", json!({})).contains("give the join `code`"));
|
||||
assert!(err(&mut s, "collab.join", json!({"code": 7})).contains("9 characters"));
|
||||
assert!(s.execute("collab.join", &json!({"code": {"x": 1}})).is_err());
|
||||
// Something that looks like a link goes to the link's reader.
|
||||
assert!(err(&mut s, "collab.join", json!({"code": "https://example.com/x"})).contains("not a live session invite"));
|
||||
assert_eq!(script.lock().unwrap().joined.len(), before);
|
||||
// A failed lookup shows on the tab it was started from (the link's event).
|
||||
ex(&mut s, "collab.join", json!({"code": "K7M-2QX-9TD"}));
|
||||
script.lock().unwrap().events.push(Event::Status(Status::Failed { reason: "No session with that code".into() }));
|
||||
s.live_poll();
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["reason"]), (&json!("failed"), &json!("No session with that code")));
|
||||
assert_eq!(s.message.as_deref(), Some("No session with that code"));
|
||||
}
|
||||
@@ -275,6 +275,10 @@ fn every_command_survives_junk_params() {
|
||||
// Open hosting: servers, owner tokens, actions, reports and the publish server as junk.
|
||||
json!({"server": "wss://\u{0}", "file": [1], "owner": {"x": 1}, "action": "remove", "reason": 5, "link": -1, "access": "admin", "url": 7}),
|
||||
json!({"server": "ws://127.0.0.1:1/", "file": "../x", "owner": "", "action": "createLink", "reason": "\u{7}\u{0}", "name": "", "url": "ws://x/?key=k"}),
|
||||
// Sharing with a join code: ways, access, codes, servers, ports and interfaces as junk.
|
||||
json!({"via": 7, "access": [1], "code": {"x": 1}, "link": -1, "server": 5, "port": -1, "bind": 3, "name": [1]}),
|
||||
json!({"via": "carrier pigeon", "access": "admin", "code": "K7M-2QX-9T\u{0}", "server": "wss://\u{0}", "port": 1e308, "bind": "::::"}),
|
||||
json!({"via": "local", "access": "view", "code": "\u{1F4A5}".repeat(64), "port": 70000, "bind": "lan"}),
|
||||
// Rotation origins and placing with a tool as junk.
|
||||
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
|
||||
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
|
||||
|
||||
@@ -92,7 +92,6 @@ status = "draft — needs native review"
|
||||
"Annotations" = "Anmerkungen"
|
||||
"Annotations shown on the canvas" = "Auf der Arbeitsfläche angezeigte Anmerkungen"
|
||||
"Any status" = "Beliebiger Status"
|
||||
"Anyone on your network who has the invite link can join." = "Jede Person in Ihrem Netzwerk mit dem Einladungslink kann beitreten."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Jede Person mit dem Bearbeitungslink kann die Datei ändern; der Ansichtslink erlaubt nur das Ansehen. Die Links enthalten ihre Geheimnisse: Teilen Sie sie wie Passwörter. `figcraft-server links` erzeugt weitere und widerruft sie."
|
||||
"Appearance" = "Darstellung"
|
||||
"Apply a component property" = "Komponenteneigenschaft anwenden"
|
||||
@@ -538,7 +537,6 @@ status = "draft — needs native review"
|
||||
"Horizontal hex" = "Horizontales Sechseck"
|
||||
"Horizontal padding" = "Horizontaler Innenabstand"
|
||||
"Horizontal spacing" = "Horizontaler Abstand"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "Zum Hosten einer Sitzung ist die Desktop-App nötig; hier können Sie einer beitreten."
|
||||
"Hover a layer to see its properties" = "Fahren Sie über eine Ebene, um ihre Eigenschaften zu sehen"
|
||||
"Hover other layers to measure the distance" = "Fahren Sie über andere Ebenen, um den Abstand zu messen"
|
||||
"Hue" = "Farbton"
|
||||
@@ -589,7 +587,6 @@ status = "draft — needs native review"
|
||||
"Invite link copied" = "Einladungslink kopiert"
|
||||
"It asks permission to:" = "Es möchte:"
|
||||
"Join" = "Beitreten"
|
||||
"Join a session…" = "Einer Sitzung beitreten…"
|
||||
"Join live session" = "Live-Sitzung beitreten"
|
||||
"Join us on Discord" = "Besuchen Sie uns auf Discord"
|
||||
"Joined" = "Beigetreten"
|
||||
@@ -802,7 +799,6 @@ status = "draft — needs native review"
|
||||
"On this page" = "Auf dieser Seite"
|
||||
"Only the first changed layers are listed." = "Nur die ersten geänderten Ebenen werden aufgeführt."
|
||||
"Only the first {n} changed layers are listed." = "Nur die ersten {n} geänderten Ebenen werden aufgeführt."
|
||||
"Only this computer can join (turn on network sharing to invite others)." = "Nur dieser Computer kann beitreten (aktivieren Sie die Netzwerkfreigabe, um andere einzuladen)."
|
||||
"Only your threads" = "Nur Ihre Threads"
|
||||
"Opacity" = "Deckkraft"
|
||||
"Opacity & blend" = "Deckkraft und Mischen"
|
||||
@@ -823,7 +819,6 @@ status = "draft — needs native review"
|
||||
"Opened “{name}” in a new tab" = "„{name}“ in neuem Tab geöffnet"
|
||||
"Open|comments" = "Offen"
|
||||
"Options" = "Optionen"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "Oder speichern Sie die Datei auf einem FigCraft-Server und teilen Sie Links zum Bearbeiten oder Ansehen."
|
||||
"Orange" = "Orange"
|
||||
"Outline stroke" = "Kontur in Pfad umwandeln"
|
||||
"Outline text" = "Text in Pfade umwandeln"
|
||||
@@ -852,7 +847,6 @@ status = "draft — needs native review"
|
||||
"Paste a link (https://…)" = "Link einfügen (https://…)"
|
||||
"Paste a team library link (a view link)" = "Link einer Teambibliothek einfügen (ein Ansichtslink)"
|
||||
"Paste here" = "Hier einfügen"
|
||||
"Paste the invite link the host shared with you." = "Fügen Sie den Einladungslink ein, den der Host mit Ihnen geteilt hat."
|
||||
"Paste to replace" = "Zum Ersetzen einfügen"
|
||||
"Pattern" = "Muster"
|
||||
"Pattern alignment" = "Musterausrichtung"
|
||||
@@ -910,7 +904,6 @@ status = "draft — needs native review"
|
||||
"Publish" = "Veröffentlichen"
|
||||
"Publish changes" = "Änderungen veröffentlichen"
|
||||
"Publish to server" = "Auf Server veröffentlichen"
|
||||
"Publish to server…" = "Auf Server veröffentlichen…"
|
||||
"Published version {v}" = "Version {v} veröffentlicht"
|
||||
"Published version {v} to the server" = "Version {v} auf dem Server veröffentlicht"
|
||||
"Published version {v}: save the file so other files can use it" = "Version {v} veröffentlicht: Speichern Sie die Datei, damit andere Dateien sie verwenden können"
|
||||
@@ -1073,7 +1066,6 @@ status = "draft — needs native review"
|
||||
"Shape builder" = "Formerstellung"
|
||||
"Shape tools" = "Formwerkzeuge"
|
||||
"Share" = "Teilen"
|
||||
"Share on the local network" = "Im lokalen Netzwerk teilen"
|
||||
"Share: live session, export" = "Teilen: Live-Sitzung, Export"
|
||||
"Show" = "Anzeigen"
|
||||
"Show as grid" = "Als Raster anzeigen"
|
||||
@@ -1132,7 +1124,6 @@ status = "draft — needs native review"
|
||||
"Start" = "Anfang"
|
||||
"Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis"
|
||||
"Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen"
|
||||
"Start live session" = "Live-Sitzung starten"
|
||||
"Start on path" = "Anfang auf dem Pfad"
|
||||
"Start point" = "Startpunkt"
|
||||
"Status" = "Status"
|
||||
@@ -1314,7 +1305,6 @@ status = "draft — needs native review"
|
||||
"Yellow" = "Gelb"
|
||||
"Yesterday" = "Gestern"
|
||||
"You" = "Sie"
|
||||
"You'll get the host's file as a new tab and edit it together." = "Sie erhalten die Datei des Hosts als neuen Tab und bearbeiten sie gemeinsam."
|
||||
"You're spotlighting: everyone follows your view" = "Ihr Spotlight ist an: Alle folgen Ihrer Ansicht"
|
||||
"Your copy of the file stays open" = "Ihre Kopie der Datei bleibt geöffnet"
|
||||
"Your name" = "Ihr Name"
|
||||
|
||||
@@ -19,6 +19,7 @@ name = "English"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long"
|
||||
"About" = "About"
|
||||
"About FigCraft" = "About FigCraft"
|
||||
"About this server" = "About this server"
|
||||
"Action" = "Action"
|
||||
"Actions" = "Actions"
|
||||
"Actions…" = "Actions…"
|
||||
@@ -62,6 +63,7 @@ name = "English"
|
||||
"Added the library \"{name}\"" = "Added the library \"{name}\""
|
||||
"Adjust" = "Adjust"
|
||||
"Adjustment presets" = "Adjustment presets"
|
||||
"Advanced" = "Advanced"
|
||||
"Advanced stroke" = "Advanced stroke"
|
||||
"After delay" = "After delay"
|
||||
"After delay {ms}ms" = "After delay {ms}ms"
|
||||
@@ -85,15 +87,19 @@ name = "English"
|
||||
"All variables" = "All variables"
|
||||
"Alpha" = "Alpha"
|
||||
"Alt+drag" = "Alt+drag"
|
||||
"An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins"
|
||||
"Anchor" = "Anchor"
|
||||
"Android" = "Android"
|
||||
"Angular" = "Angular"
|
||||
"Animation" = "Animation"
|
||||
"Annotations" = "Annotations"
|
||||
"Annotations shown on the canvas" = "Annotations shown on the canvas"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences."
|
||||
"Any status" = "Any status"
|
||||
"Anyone on your network who has the invite link can join." = "Anyone on your network who has the invite link can join."
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "Anyone on your network with this code can edit the file, while this app stays open."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them."
|
||||
"Anyone with this code can edit the file." = "Anyone with this code can edit the file."
|
||||
"Anyone with this code can look at the file, not change it." = "Anyone with this code can look at the file, not change it."
|
||||
"Appearance" = "Appearance"
|
||||
"Apply a component property" = "Apply a component property"
|
||||
"Apply variable" = "Apply variable"
|
||||
@@ -121,6 +127,7 @@ name = "English"
|
||||
"Auto layout" = "Auto layout"
|
||||
"Auto rows" = "Auto rows"
|
||||
"Auto width" = "Auto width"
|
||||
"Back to the FigCraft server" = "Back to the FigCraft server"
|
||||
"Back to the font's value" = "Back to the font's value"
|
||||
"Back" = "Back"
|
||||
"Back to Ready for dev" = "Back to Ready for dev"
|
||||
@@ -176,6 +183,8 @@ name = "English"
|
||||
"C or Shift+C" = "C or Shift+C"
|
||||
"C or ⇧C" = "C or ⇧C"
|
||||
"CMYK colour" = "CMYK colour"
|
||||
"Can view" = "Can view"
|
||||
"Code or invite link" = "Code or invite link"
|
||||
"Color profile: {name}" = "Color profile: {name}"
|
||||
"CSS custom properties…" = "CSS custom properties…"
|
||||
"Can edit" = "Can edit"
|
||||
@@ -282,6 +291,7 @@ name = "English"
|
||||
"Copy as CSS" = "Copy as CSS"
|
||||
"Copy as PNG" = "Copy as PNG"
|
||||
"Copy as SVG" = "Copy as SVG"
|
||||
"Copy code" = "Copy code"
|
||||
"Copy link" = "Copy link"
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again." = "Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again."
|
||||
"Copy the {label}" = "Copy the {label}"
|
||||
@@ -404,6 +414,7 @@ name = "English"
|
||||
"Empty" = "Empty"
|
||||
"End" = "End"
|
||||
"End point" = "End point"
|
||||
"Ends the session; everyone keeps their copy" = "Ends the session; everyone keeps their copy"
|
||||
"Enter" = "Enter"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Enter the address of a figcraft-server and press Browse to see the plugins it offers."
|
||||
"Enter to save · Esc to cancel" = "Enter to save · Esc to cancel"
|
||||
@@ -526,6 +537,7 @@ name = "English"
|
||||
"Help" = "Help"
|
||||
"Help and resources" = "Help and resources"
|
||||
"Help page" = "Help page"
|
||||
"Here: {names}" = "Here: {names}"
|
||||
"Hidden" = "Hidden"
|
||||
"Hide contents" = "Hide contents"
|
||||
"Hide from publishing" = "Hide from publishing"
|
||||
@@ -546,7 +558,6 @@ name = "English"
|
||||
"Horizontal padding" = "Horizontal padding"
|
||||
"Horizontal spacing" = "Horizontal spacing"
|
||||
"Hosted by {operator}" = "Hosted by {operator}"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "Hosting a session needs the desktop app; here you can join one."
|
||||
"Hover a layer to see its properties" = "Hover a layer to see its properties"
|
||||
"Hover other layers to measure the distance" = "Hover other layers to measure the distance"
|
||||
"Hue" = "Hue"
|
||||
@@ -597,9 +608,11 @@ name = "English"
|
||||
"Invite link copied" = "Invite link copied"
|
||||
"It asks permission to:" = "It asks permission to:"
|
||||
"Join" = "Join"
|
||||
"Join a session…" = "Join a session…"
|
||||
"Join code copied" = "Join code copied"
|
||||
"Join code or invite link" = "Join code or invite link"
|
||||
"Join live session" = "Join live session"
|
||||
"Join us on Discord" = "Join us on Discord"
|
||||
"Join with a code" = "Join with a code"
|
||||
"Joined" = "Joined"
|
||||
"Joined {file}" = "Joined {file}"
|
||||
"Joining…" = "Joining…"
|
||||
@@ -662,6 +675,7 @@ name = "English"
|
||||
"Live session" = "Live session"
|
||||
"Live · {people}" = "Live · {people}"
|
||||
"Local components" = "Local components"
|
||||
"Local network" = "Local network"
|
||||
"Local styles" = "Local styles"
|
||||
"Local variables" = "Local variables"
|
||||
"Lock/Unlock" = "Lock/Unlock"
|
||||
@@ -751,6 +765,7 @@ name = "English"
|
||||
"No conflicts." = "No conflicts."
|
||||
"No contributor data was built into this copy." = "No contributor data was built into this copy."
|
||||
"No fonts match" = "No fonts match"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead."
|
||||
"No layer changes to show." = "No layer changes to show."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link."
|
||||
"No list" = "No list"
|
||||
@@ -808,9 +823,10 @@ name = "English"
|
||||
"On click" = "On click"
|
||||
"On drag" = "On drag"
|
||||
"On this page" = "On this page"
|
||||
"Online" = "Online"
|
||||
"Online sharing is off: files are shared on the local network only." = "Online sharing is off: files are shared on the local network only."
|
||||
"Only the first changed layers are listed." = "Only the first changed layers are listed."
|
||||
"Only the first {n} changed layers are listed." = "Only the first {n} changed layers are listed."
|
||||
"Only this computer can join (turn on network sharing to invite others)." = "Only this computer can join (turn on network sharing to invite others)."
|
||||
"Only your threads" = "Only your threads"
|
||||
"Opacity" = "Opacity"
|
||||
"Opacity & blend" = "Opacity & blend"
|
||||
@@ -831,9 +847,9 @@ name = "English"
|
||||
"Opened “{name}” in a new tab" = "Opened “{name}” in a new tab"
|
||||
"Open|comments" = "Open"
|
||||
"Options" = "Options"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "Or keep the file on a FigCraft server and share links to edit or to view it."
|
||||
"Orange" = "Orange"
|
||||
"Other apps may not open every .fig file exported here." = "Other apps may not open every .fig file exported here."
|
||||
"Others type this under Share ▸ Join with a code" = "Others type this under Share ▸ Join with a code"
|
||||
"Outline stroke" = "Outline stroke"
|
||||
"Outline text" = "Outline text"
|
||||
"Outlines" = "Outlines"
|
||||
@@ -863,7 +879,6 @@ name = "English"
|
||||
"Paste a link (https://…)" = "Paste a link (https://…)"
|
||||
"Paste a team library link (a view link)" = "Paste a team library link (a view link)"
|
||||
"Paste here" = "Paste here"
|
||||
"Paste the invite link the host shared with you." = "Paste the invite link the host shared with you."
|
||||
"Paste to replace" = "Paste to replace"
|
||||
"Pattern" = "Pattern"
|
||||
"Pattern alignment" = "Pattern alignment"
|
||||
@@ -872,6 +887,8 @@ name = "English"
|
||||
"Pen" = "Pen"
|
||||
"Pencil" = "Pencil"
|
||||
"People in this file" = "People in this file"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "People join from anywhere with a code. The file is kept on {server} while you share it."
|
||||
"People on your network join with a code. Nothing goes through a server." = "People on your network join with a code. Nothing goes through a server."
|
||||
"Phone" = "Phone"
|
||||
"Pick color" = "Pick color"
|
||||
"Pick color (I)" = "Pick color (I)"
|
||||
@@ -892,6 +909,7 @@ name = "English"
|
||||
"Points" = "Points"
|
||||
"Polygon" = "Polygon"
|
||||
"Polygons" = "Polygons"
|
||||
"Port" = "Port"
|
||||
"Position" = "Position"
|
||||
"Post" = "Post"
|
||||
"Preferences" = "Preferences"
|
||||
@@ -920,8 +938,8 @@ name = "English"
|
||||
"Prototype settings" = "Prototype settings"
|
||||
"Publish" = "Publish"
|
||||
"Publish changes" = "Publish changes"
|
||||
"Publish to another server…" = "Publish to another server…"
|
||||
"Publish to server" = "Publish to server"
|
||||
"Publish to server…" = "Publish to server…"
|
||||
"Published version {v}" = "Published version {v}"
|
||||
"Published version {v} to the server" = "Published version {v} to the server"
|
||||
"Published version {v}: save the file so other files can use it" = "Published version {v}: save the file so other files can use it"
|
||||
@@ -970,6 +988,7 @@ name = "English"
|
||||
"Remove this condition and its actions" = "Remove this condition and its actions"
|
||||
"Removed" = "Removed"
|
||||
"Removed since it was marked" = "Removed since it was marked"
|
||||
"Removes the file from the server; everyone keeps their copy" = "Removes the file from the server; everyone keeps their copy"
|
||||
"Rename" = "Rename"
|
||||
"Rename duplicated layers" = "Rename duplicated layers"
|
||||
"Rename folder" = "Rename folder"
|
||||
@@ -1078,7 +1097,7 @@ name = "English"
|
||||
"Send to back" = "Send to back"
|
||||
"Server address (ws://host:47900/)" = "Server address (ws://host:47900/)"
|
||||
"Server address" = "Server address"
|
||||
"Server to publish to" = "Server to publish to"
|
||||
"Server for online sharing" = "Server for online sharing"
|
||||
"Set as default" = "Set as default"
|
||||
"Set variable" = "Set variable"
|
||||
"Set variable mode" = "Set variable mode"
|
||||
@@ -1089,8 +1108,12 @@ name = "English"
|
||||
"Shape builder" = "Shape builder"
|
||||
"Shape tools" = "Shape tools"
|
||||
"Share" = "Share"
|
||||
"Share on the local network" = "Share on the local network"
|
||||
"Share this file" = "Share this file"
|
||||
"Share: live session, export" = "Share: live session, export"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Sharing a file needs the desktop app; here you can join one."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Sharing on the local network (online sharing is off: no server is set in Preferences)."
|
||||
"Sharing on your network · {people}" = "Sharing on your network · {people}"
|
||||
"Sharing online · {people}" = "Sharing online · {people}"
|
||||
"Show" = "Show"
|
||||
"Show as grid" = "Show as grid"
|
||||
"Show as list" = "Show as list"
|
||||
@@ -1149,14 +1172,16 @@ name = "English"
|
||||
"Start" = "Start"
|
||||
"Start ascending from / stop descending at" = "Start ascending from / stop descending at"
|
||||
"Start from a blank canvas" = "Start from a blank canvas"
|
||||
"Start live session" = "Start live session"
|
||||
"Start on path" = "Start on path"
|
||||
"Start point" = "Start point"
|
||||
"Start sharing" = "Start sharing"
|
||||
"Starting to share…" = "Starting to share…"
|
||||
"Status" = "Status"
|
||||
"Sticky" = "Sticky"
|
||||
"Stop" = "Stop"
|
||||
"Stop following {name}" = "Stop following {name}"
|
||||
"Stop session" = "Stop session"
|
||||
"Stop sharing" = "Stop sharing"
|
||||
"Stop spotlighting" = "Stop spotlighting"
|
||||
"Stop {n} {hex}" = "Stop {n} {hex}"
|
||||
"Stretch" = "Stretch"
|
||||
@@ -1218,6 +1243,7 @@ name = "English"
|
||||
"Thickness" = "Thickness"
|
||||
"This branch can't be reviewed: {e}" = "This branch can't be reviewed: {e}"
|
||||
"This branch was merged or archived." = "This branch was merged or archived."
|
||||
"This computer only" = "This computer only"
|
||||
"This condition is edited as an expression" = "This condition is edited as an expression"
|
||||
"This file" = "This file"
|
||||
"This file has unsaved changes" = "This file has unsaved changes"
|
||||
@@ -1225,6 +1251,7 @@ name = "English"
|
||||
"This frame isn't on the branch." = "This frame isn't on the branch."
|
||||
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."
|
||||
"This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced."
|
||||
"Tidy up" = "Tidy up"
|
||||
"Tile" = "Tile"
|
||||
"Tile type" = "Tile type"
|
||||
@@ -1250,6 +1277,7 @@ name = "English"
|
||||
"Two digits" = "Two digits"
|
||||
"Type" = "Type"
|
||||
"Type settings" = "Type settings"
|
||||
"Type the join code you were given, or paste an invite link." = "Type the join code you were given, or paste an invite link."
|
||||
"Typography" = "Typography"
|
||||
"Underline" = "Underline"
|
||||
"Undo takes back only your own changes; what others changed since stays." = "Undo takes back only your own changes; what others changed since stays."
|
||||
@@ -1327,6 +1355,7 @@ name = "English"
|
||||
"While hovering" = "While hovering"
|
||||
"While pressing" = "While pressing"
|
||||
"White" = "White"
|
||||
"Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files"
|
||||
"Why it changed" = "Why it changed"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Width"
|
||||
@@ -1338,7 +1367,7 @@ name = "English"
|
||||
"Yellow" = "Yellow"
|
||||
"Yesterday" = "Yesterday"
|
||||
"You" = "You"
|
||||
"You'll get the host's file as a new tab and edit it together." = "You'll get the host's file as a new tab and edit it together."
|
||||
"You'll get the shared file as a new tab and edit it together." = "You'll get the shared file as a new tab and edit it together."
|
||||
"You're spotlighting: everyone follows your view" = "You're spotlighting: everyone follows your view"
|
||||
"Your copy of the file stays open" = "Your copy of the file stays open"
|
||||
"Your name" = "Your name"
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — needs native review"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un muelle se reproduce hasta que se asienta: su masa, rigidez y amortiguación fijan cuánto dura"
|
||||
"About" = "Acerca de"
|
||||
"About FigCraft" = "Acerca de FigCraft"
|
||||
"About this server" = "Acerca de este servidor"
|
||||
"Action" = "Acción"
|
||||
"Actions" = "Acciones"
|
||||
"Actions…" = "Acciones…"
|
||||
@@ -63,6 +64,7 @@ status = "draft — needs native review"
|
||||
"Added the library \"{name}\"" = "Biblioteca \"{name}\" añadida"
|
||||
"Adjust" = "Ajustar"
|
||||
"Adjustment presets" = "Preajustes"
|
||||
"Advanced" = "Avanzado"
|
||||
"Advanced stroke" = "Trazo avanzado"
|
||||
"After delay" = "Tras un retardo"
|
||||
"After delay {ms}ms" = "Tras {ms}ms"
|
||||
@@ -86,15 +88,19 @@ status = "draft — needs native review"
|
||||
"All variables" = "Todas las variables"
|
||||
"Alpha" = "Alfa"
|
||||
"Alt+drag" = "Alt+arrastrar"
|
||||
"An invite link that opens FigCraft and joins" = "Un enlace de invitación que abre FigCraft y se une"
|
||||
"Anchor" = "Ancla"
|
||||
"Android" = "Android"
|
||||
"Angular" = "Angular"
|
||||
"Animation" = "Animación"
|
||||
"Annotations" = "Anotaciones"
|
||||
"Annotations shown on the canvas" = "Anotaciones visibles en el lienzo"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "Otro servidor, o uno que necesita su clave. El servidor habitual se elige en Preferencias."
|
||||
"Any status" = "Cualquier estado"
|
||||
"Anyone on your network who has the invite link can join." = "Cualquiera en tu red que tenga el enlace de invitación puede unirse."
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "Cualquiera en tu red con este código puede editar el archivo, mientras esta aplicación siga abierta."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Cualquiera con el enlace de edición puede cambiar el archivo; el enlace de lectura solo permite verlo. Los enlaces llevan sus claves secretas: compártelos como contraseñas. `figcraft-server links` crea más y los revoca."
|
||||
"Anyone with this code can edit the file." = "Cualquiera con este código puede editar el archivo."
|
||||
"Anyone with this code can look at the file, not change it." = "Cualquiera con este código puede ver el archivo, no cambiarlo."
|
||||
"Appearance" = "Apariencia"
|
||||
"Apply a component property" = "Aplicar una propiedad de componente"
|
||||
"Apply variable" = "Aplicar variable"
|
||||
@@ -122,6 +128,7 @@ status = "draft — needs native review"
|
||||
"Auto layout" = "Diseño automático"
|
||||
"Auto rows" = "Filas automáticas"
|
||||
"Auto width" = "Ancho automático"
|
||||
"Back to the FigCraft server" = "Volver al servidor de FigCraft"
|
||||
"Back to the font's value" = "Volver al valor de la fuente"
|
||||
"Back" = "Atrás"
|
||||
"Back to Ready for dev" = "Volver a Listo para desarrollo"
|
||||
@@ -177,6 +184,8 @@ status = "draft — needs native review"
|
||||
"C or Shift+C" = "C o Shift+C"
|
||||
"C or ⇧C" = "C o ⇧C"
|
||||
"CMYK colour" = "Color CMYK"
|
||||
"Can view" = "Puede ver"
|
||||
"Code or invite link" = "Código o enlace de invitación"
|
||||
"Color profile: {name}" = "Perfil de color: {name}"
|
||||
"CSS custom properties…" = "Propiedades personalizadas CSS…"
|
||||
"Can edit" = "Puede editar"
|
||||
@@ -283,6 +292,7 @@ status = "draft — needs native review"
|
||||
"Copy as CSS" = "Copiar como CSS"
|
||||
"Copy as PNG" = "Copiar como PNG"
|
||||
"Copy as SVG" = "Copiar como SVG"
|
||||
"Copy code" = "Copiar código"
|
||||
"Copy link" = "Copiar enlace"
|
||||
"Copy the owner token and keep it somewhere safe: it is the only way to revoke these links, rename the file or delete it from the server, and it won't be shown again." = "Copia el token de propietario y guárdalo en un lugar seguro: es la única forma de revocar estos enlaces, cambiar el nombre del archivo o eliminarlo del servidor, y no se volverá a mostrar."
|
||||
"Copy the {label}" = "Copiar {label}"
|
||||
@@ -405,6 +415,7 @@ status = "draft — needs native review"
|
||||
"Empty" = "Vacía"
|
||||
"End" = "Final"
|
||||
"End point" = "Punto final"
|
||||
"Ends the session; everyone keeps their copy" = "Termina la sesión; cada persona conserva su copia"
|
||||
"Enter" = "Intro"
|
||||
"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Escribe la dirección de un figcraft-server y pulsa Explorar para ver los plugins que ofrece."
|
||||
"Enter to save · Esc to cancel" = "Intro para guardar · Esc para cancelar"
|
||||
@@ -527,6 +538,7 @@ status = "draft — needs native review"
|
||||
"Help" = "Ayuda"
|
||||
"Help and resources" = "Ayuda y recursos"
|
||||
"Help page" = "Página de ayuda"
|
||||
"Here: {names}" = "Aquí: {names}"
|
||||
"Hidden" = "Oculta"
|
||||
"Hide contents" = "Ocultar contenido"
|
||||
"Hide from publishing" = "Ocultar al publicar"
|
||||
@@ -547,7 +559,6 @@ status = "draft — needs native review"
|
||||
"Horizontal padding" = "Margen interno horizontal"
|
||||
"Horizontal spacing" = "Espaciado horizontal"
|
||||
"Hosted by {operator}" = "Alojado por {operator}"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "Para alojar una sesión necesitas la app de escritorio; aquí puedes unirte a una."
|
||||
"Hover a layer to see its properties" = "Pasa el puntero sobre una capa para ver sus propiedades"
|
||||
"Hover other layers to measure the distance" = "Pasa sobre otras capas para medir la distancia"
|
||||
"Hue" = "Tono"
|
||||
@@ -598,9 +609,11 @@ status = "draft — needs native review"
|
||||
"Invite link copied" = "Enlace de invitación copiado"
|
||||
"It asks permission to:" = "Pide permiso para:"
|
||||
"Join" = "Unirse"
|
||||
"Join a session…" = "Unirse a una sesión…"
|
||||
"Join code copied" = "Código para unirse copiado"
|
||||
"Join code or invite link" = "Código para unirse o enlace de invitación"
|
||||
"Join live session" = "Unirse a sesión en vivo"
|
||||
"Join us on Discord" = "Únete a nosotros en Discord"
|
||||
"Join with a code" = "Unirse con un código"
|
||||
"Joined" = "Conectado"
|
||||
"Joined {file}" = "Conectado a {file}"
|
||||
"Joining…" = "Uniéndose…"
|
||||
@@ -663,6 +676,7 @@ status = "draft — needs native review"
|
||||
"Live session" = "Sesión en vivo"
|
||||
"Live · {people}" = "En vivo · {people}"
|
||||
"Local components" = "Componentes locales"
|
||||
"Local network" = "Red local"
|
||||
"Local styles" = "Estilos locales"
|
||||
"Local variables" = "Variables locales"
|
||||
"Lock/Unlock" = "Bloquear/desbloquear"
|
||||
@@ -752,6 +766,7 @@ status = "draft — needs native review"
|
||||
"No conflicts." = "Sin conflictos."
|
||||
"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores."
|
||||
"No fonts match" = "Ninguna fuente coincide"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace."
|
||||
"No layer changes to show." = "No hay cambios de capas que mostrar."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo."
|
||||
"No list" = "Sin lista"
|
||||
@@ -809,9 +824,10 @@ status = "draft — needs native review"
|
||||
"On click" = "Al hacer clic"
|
||||
"On drag" = "Al arrastrar"
|
||||
"On this page" = "En esta página"
|
||||
"Online" = "En línea"
|
||||
"Online sharing is off: files are shared on the local network only." = "Compartir en línea está desactivado: los archivos solo se comparten en la red local."
|
||||
"Only the first changed layers are listed." = "Solo se muestran las primeras capas cambiadas."
|
||||
"Only the first {n} changed layers are listed." = "Solo se muestran las primeras {n} capas cambiadas."
|
||||
"Only this computer can join (turn on network sharing to invite others)." = "Solo este equipo puede unirse (activa el uso compartido en red para invitar a otros)."
|
||||
"Only your threads" = "Solo tus hilos"
|
||||
"Opacity" = "Opacidad"
|
||||
"Opacity & blend" = "Opacidad y fusión"
|
||||
@@ -832,9 +848,9 @@ status = "draft — needs native review"
|
||||
"Opened “{name}” in a new tab" = "“{name}” abierta en una pestaña nueva"
|
||||
"Open|comments" = "Abiertos"
|
||||
"Options" = "Opciones"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "O guarda el archivo en un servidor FigCraft y comparte enlaces para editarlo o verlo."
|
||||
"Orange" = "Naranja"
|
||||
"Other apps may not open every .fig file exported here." = "Puede que otras aplicaciones no abran todos los archivos .fig exportados aquí."
|
||||
"Others type this under Share ▸ Join with a code" = "Los demás lo escriben en Compartir ▸ Unirse con un código"
|
||||
"Outline stroke" = "Contornear trazo"
|
||||
"Outline text" = "Texto como contornos"
|
||||
"Outlines" = "Contornos"
|
||||
@@ -864,7 +880,6 @@ status = "draft — needs native review"
|
||||
"Paste a link (https://…)" = "Pega un enlace (https://…)"
|
||||
"Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)"
|
||||
"Paste here" = "Pegar aquí"
|
||||
"Paste the invite link the host shared with you." = "Pega el enlace de invitación que te compartió el anfitrión."
|
||||
"Paste to replace" = "Pegar para reemplazar"
|
||||
"Pattern" = "Patrón"
|
||||
"Pattern alignment" = "Alineación del patrón"
|
||||
@@ -873,6 +888,8 @@ status = "draft — needs native review"
|
||||
"Pen" = "Pluma"
|
||||
"Pencil" = "Lápiz"
|
||||
"People in this file" = "Personas en este archivo"
|
||||
"People join from anywhere with a code. The file is kept on {server} while you share it." = "La gente se une desde cualquier lugar con un código. El archivo se guarda en {server} mientras lo compartes."
|
||||
"People on your network join with a code. Nothing goes through a server." = "La gente de tu red se une con un código. Nada pasa por un servidor."
|
||||
"Phone" = "Teléfono"
|
||||
"Pick color" = "Elegir color"
|
||||
"Pick color (I)" = "Tomar color (I)"
|
||||
@@ -893,6 +910,7 @@ status = "draft — needs native review"
|
||||
"Points" = "Puntos"
|
||||
"Polygon" = "Polígono"
|
||||
"Polygons" = "Polígonos"
|
||||
"Port" = "Puerto"
|
||||
"Position" = "Posición"
|
||||
"Post" = "Publicar"
|
||||
"Preferences" = "Preferencias"
|
||||
@@ -921,8 +939,8 @@ status = "draft — needs native review"
|
||||
"Prototype settings" = "Ajustes del prototipo"
|
||||
"Publish" = "Publicar"
|
||||
"Publish changes" = "Publicar cambios"
|
||||
"Publish to another server…" = "Publicar en otro servidor…"
|
||||
"Publish to server" = "Publicar en servidor"
|
||||
"Publish to server…" = "Publicar en servidor…"
|
||||
"Published version {v}" = "Versión {v} publicada"
|
||||
"Published version {v} to the server" = "Versión {v} publicada en el servidor"
|
||||
"Published version {v}: save the file so other files can use it" = "Versión {v} publicada: guarda el archivo para que otros archivos puedan usarla"
|
||||
@@ -971,6 +989,7 @@ status = "draft — needs native review"
|
||||
"Remove this condition and its actions" = "Quitar esta condición y sus acciones"
|
||||
"Removed" = "Eliminado"
|
||||
"Removed since it was marked" = "Eliminada desde que se marcó"
|
||||
"Removes the file from the server; everyone keeps their copy" = "Quita el archivo del servidor; cada persona conserva su copia"
|
||||
"Rename" = "Cambiar nombre"
|
||||
"Rename duplicated layers" = "Renombrar capas duplicadas"
|
||||
"Rename folder" = "Cambiar nombre de la carpeta"
|
||||
@@ -1079,7 +1098,7 @@ status = "draft — needs native review"
|
||||
"Send to back" = "Enviar al fondo"
|
||||
"Server address (ws://host:47900/)" = "Dirección del servidor (ws://host:47900/)"
|
||||
"Server address" = "Dirección del servidor"
|
||||
"Server to publish to" = "Servidor donde publicar"
|
||||
"Server for online sharing" = "Servidor para compartir en línea"
|
||||
"Set as default" = "Establecer como predeterminado"
|
||||
"Set variable" = "Establecer variable"
|
||||
"Set variable mode" = "Establecer modo de variable"
|
||||
@@ -1090,8 +1109,12 @@ status = "draft — needs native review"
|
||||
"Shape builder" = "Generador de formas"
|
||||
"Shape tools" = "Herramientas de forma"
|
||||
"Share" = "Compartir"
|
||||
"Share on the local network" = "Compartir en la red local"
|
||||
"Share this file" = "Compartir este archivo"
|
||||
"Share: live session, export" = "Compartir: sesión en vivo, exportar"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Compartir un archivo requiere la aplicación de escritorio; aquí puedes unirte a uno."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Se comparte en la red local (compartir en línea está desactivado: no hay servidor en Preferencias)."
|
||||
"Sharing on your network · {people}" = "Compartiendo en tu red · {people}"
|
||||
"Sharing online · {people}" = "Compartiendo en línea · {people}"
|
||||
"Show" = "Mostrar"
|
||||
"Show as grid" = "Mostrar como cuadrícula"
|
||||
"Show as list" = "Mostrar como lista"
|
||||
@@ -1150,14 +1173,16 @@ status = "draft — needs native review"
|
||||
"Start" = "Inicio"
|
||||
"Start ascending from / stop descending at" = "Contar hacia arriba desde / hacia abajo hasta"
|
||||
"Start from a blank canvas" = "Empieza con un lienzo en blanco"
|
||||
"Start live session" = "Iniciar sesión en vivo"
|
||||
"Start on path" = "Inicio en el trazado"
|
||||
"Start point" = "Punto inicial"
|
||||
"Start sharing" = "Empezar a compartir"
|
||||
"Starting to share…" = "Empezando a compartir…"
|
||||
"Status" = "Estado"
|
||||
"Sticky" = "Adherido"
|
||||
"Stop" = "Parar"
|
||||
"Stop following {name}" = "Dejar de seguir a {name}"
|
||||
"Stop session" = "Detener sesión"
|
||||
"Stop sharing" = "Dejar de compartir"
|
||||
"Stop spotlighting" = "Salir del foco"
|
||||
"Stop {n} {hex}" = "Parada {n} {hex}"
|
||||
"Stretch" = "Estirar"
|
||||
@@ -1219,6 +1244,7 @@ status = "draft — needs native review"
|
||||
"Thickness" = "Grosor"
|
||||
"This branch can't be reviewed: {e}" = "Esta rama no se puede revisar: {e}"
|
||||
"This branch was merged or archived." = "Esta rama se fusionó o archivó."
|
||||
"This computer only" = "Solo este ordenador"
|
||||
"This condition is edited as an expression" = "Esta condición se edita como expresión"
|
||||
"This file" = "Este archivo"
|
||||
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
|
||||
@@ -1226,6 +1252,7 @@ status = "draft — needs native review"
|
||||
"This frame isn't on the branch." = "Este marco no está en la rama."
|
||||
"This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: añade ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)."
|
||||
"This server takes files only with its key: see Advanced." = "Este servidor solo acepta archivos con su clave: mira Avanzado."
|
||||
"Tidy up" = "Ordenar"
|
||||
"Tile" = "Mosaico"
|
||||
"Tile type" = "Tipo de mosaico"
|
||||
@@ -1251,6 +1278,7 @@ status = "draft — needs native review"
|
||||
"Two digits" = "Dos dígitos"
|
||||
"Type" = "Tipo"
|
||||
"Type settings" = "Ajustes de texto"
|
||||
"Type the join code you were given, or paste an invite link." = "Escribe el código para unirte que te dieron, o pega un enlace de invitación."
|
||||
"Typography" = "Tipografía"
|
||||
"Underline" = "Subrayado"
|
||||
"Undo takes back only your own changes; what others changed since stays." = "Deshacer revierte solo tus propios cambios; lo que otros hayan cambiado después se mantiene."
|
||||
@@ -1328,6 +1356,7 @@ status = "draft — needs native review"
|
||||
"While hovering" = "Mientras se pasa el puntero"
|
||||
"While pressing" = "Mientras se presiona"
|
||||
"White" = "Blanco"
|
||||
"Who runs it, its terms and how long it keeps files" = "Quién lo gestiona, sus condiciones y cuánto tiempo conserva los archivos"
|
||||
"Why it changed" = "Por qué cambió"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Ancho"
|
||||
@@ -1339,7 +1368,7 @@ status = "draft — needs native review"
|
||||
"Yellow" = "Amarillo"
|
||||
"Yesterday" = "Ayer"
|
||||
"You" = "Tú"
|
||||
"You'll get the host's file as a new tab and edit it together." = "Recibirás el archivo del anfitrión en una pestaña nueva para editarlo en conjunto."
|
||||
"You'll get the shared file as a new tab and edit it together." = "Recibirás el archivo compartido en una pestaña nueva y lo editaréis en conjunto."
|
||||
"You're spotlighting: everyone follows your view" = "Estás en el foco: todos siguen tu vista"
|
||||
"Your copy of the file stays open" = "Tu copia del archivo sigue abierta"
|
||||
"Your name" = "Tu nombre"
|
||||
@@ -1506,6 +1535,7 @@ status = "draft — needs native review"
|
||||
"collab.registerLinks" = "Abrir enlaces de invitación en FigCraft"
|
||||
"collab.report" = "Denunciar archivo al operador del servidor"
|
||||
"collab.serverInfo" = "Información del servidor"
|
||||
"collab.share" = "Compartir con un código para unirse"
|
||||
"collab.spotlight" = "Ponerme en el foco"
|
||||
"collab.stop" = "Detener sesión en vivo"
|
||||
"command.batch" = "Ejecutar varios comandos"
|
||||
@@ -1724,7 +1754,7 @@ status = "draft — needs native review"
|
||||
"pref.language" = "Idioma de la interfaz"
|
||||
"pref.nudge" = "Cantidad de desplazamiento"
|
||||
"pref.numberKeysOpacity" = "Usar teclas numéricas para la opacidad"
|
||||
"pref.publishServer" = "Servidor donde publicar"
|
||||
"pref.publishServer" = "Servidor para compartir en línea"
|
||||
"pref.reduceMotion" = "Reducir movimiento"
|
||||
"pref.renameDuplicates" = "Renombrar capas duplicadas"
|
||||
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
|
||||
|
||||
@@ -92,7 +92,6 @@ status = "draft — needs native review"
|
||||
"Annotations" = "Annotations"
|
||||
"Annotations shown on the canvas" = "Annotations affichées sur le canevas"
|
||||
"Any status" = "Tous les statuts"
|
||||
"Anyone on your network who has the invite link can join." = "Toute personne de votre réseau disposant du lien d’invitation peut rejoindre la session."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Toute personne disposant du lien de modification peut modifier le fichier ; le lien de consultation permet seulement de le regarder. Les liens contiennent leurs secrets : partagez-les comme des mots de passe. `figcraft-server links` en crée d’autres et les révoque."
|
||||
"Appearance" = "Apparence"
|
||||
"Apply a component property" = "Appliquer une propriété de composant"
|
||||
@@ -538,7 +537,6 @@ status = "draft — needs native review"
|
||||
"Horizontal hex" = "Hexagone horizontal"
|
||||
"Horizontal padding" = "Marge intérieure horizontale"
|
||||
"Horizontal spacing" = "Espacement horizontal"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "Héberger une session nécessite l’application de bureau ; ici, vous pouvez en rejoindre une."
|
||||
"Hover a layer to see its properties" = "Survolez un calque pour voir ses propriétés"
|
||||
"Hover other layers to measure the distance" = "Survolez d’autres calques pour mesurer la distance"
|
||||
"Hue" = "Teinte"
|
||||
@@ -589,7 +587,6 @@ status = "draft — needs native review"
|
||||
"Invite link copied" = "Lien d’invitation copié"
|
||||
"It asks permission to:" = "Il demande l’autorisation de :"
|
||||
"Join" = "Rejoindre"
|
||||
"Join a session…" = "Rejoindre une session…"
|
||||
"Join live session" = "Rejoindre la session en direct"
|
||||
"Join us on Discord" = "Rejoignez-nous sur Discord"
|
||||
"Joined" = "Connecté"
|
||||
@@ -802,7 +799,6 @@ status = "draft — needs native review"
|
||||
"On this page" = "Sur cette page"
|
||||
"Only the first changed layers are listed." = "Seuls les premiers calques modifiés sont listés."
|
||||
"Only the first {n} changed layers are listed." = "Seuls les {n} premiers calques modifiés sont listés."
|
||||
"Only this computer can join (turn on network sharing to invite others)." = "Seul cet ordinateur peut se connecter (activez le partage réseau pour inviter d’autres personnes)."
|
||||
"Only your threads" = "Vos fils uniquement"
|
||||
"Opacity" = "Opacité"
|
||||
"Opacity & blend" = "Opacité et fusion"
|
||||
@@ -823,7 +819,6 @@ status = "draft — needs native review"
|
||||
"Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet"
|
||||
"Open|comments" = "Ouverts"
|
||||
"Options" = "Options"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "Ou gardez le fichier sur un serveur FigCraft et partagez des liens pour le modifier ou le consulter."
|
||||
"Orange" = "Orange"
|
||||
"Outline stroke" = "Convertir le contour en tracé"
|
||||
"Outline text" = "Convertir le texte en tracés"
|
||||
@@ -852,7 +847,6 @@ status = "draft — needs native review"
|
||||
"Paste a link (https://…)" = "Collez un lien (https://…)"
|
||||
"Paste a team library link (a view link)" = "Collez le lien d’une bibliothèque d’équipe (un lien de consultation)"
|
||||
"Paste here" = "Coller ici"
|
||||
"Paste the invite link the host shared with you." = "Collez le lien d’invitation que l’hôte vous a envoyé."
|
||||
"Paste to replace" = "Coller pour remplacer"
|
||||
"Pattern" = "Motif"
|
||||
"Pattern alignment" = "Alignement du motif"
|
||||
@@ -910,7 +904,6 @@ status = "draft — needs native review"
|
||||
"Publish" = "Publier"
|
||||
"Publish changes" = "Publier les modifications"
|
||||
"Publish to server" = "Publier sur un serveur"
|
||||
"Publish to server…" = "Publier sur un serveur…"
|
||||
"Published version {v}" = "Version {v} publiée"
|
||||
"Published version {v} to the server" = "Version {v} publiée sur le serveur"
|
||||
"Published version {v}: save the file so other files can use it" = "Version {v} publiée : enregistrez le fichier pour que d’autres fichiers puissent l’utiliser"
|
||||
@@ -1073,7 +1066,6 @@ status = "draft — needs native review"
|
||||
"Shape builder" = "Générateur de formes"
|
||||
"Shape tools" = "Outils de forme"
|
||||
"Share" = "Partager"
|
||||
"Share on the local network" = "Partager sur le réseau local"
|
||||
"Share: live session, export" = "Partager : session en direct, exportation"
|
||||
"Show" = "Afficher"
|
||||
"Show as grid" = "Afficher en grille"
|
||||
@@ -1132,7 +1124,6 @@ status = "draft — needs native review"
|
||||
"Start" = "Début"
|
||||
"Start ascending from / stop descending at" = "Compter vers le haut depuis / vers le bas jusqu’à"
|
||||
"Start from a blank canvas" = "Commencer avec un canevas vide"
|
||||
"Start live session" = "Démarrer une session en direct"
|
||||
"Start on path" = "Début sur le tracé"
|
||||
"Start point" = "Point de départ"
|
||||
"Status" = "Statut"
|
||||
@@ -1314,7 +1305,6 @@ status = "draft — needs native review"
|
||||
"Yellow" = "Jaune"
|
||||
"Yesterday" = "Hier"
|
||||
"You" = "Vous"
|
||||
"You'll get the host's file as a new tab and edit it together." = "Vous recevrez le fichier de l’hôte dans un nouvel onglet pour le modifier ensemble."
|
||||
"You're spotlighting: everyone follows your view" = "Vous êtes sous les projecteurs : tout le monde suit votre vue"
|
||||
"Your copy of the file stays open" = "Votre copie du fichier reste ouverte"
|
||||
"Your name" = "Votre nom"
|
||||
|
||||
@@ -94,7 +94,6 @@ status = "draft — needs native review"
|
||||
"Annotations" = "注釈"
|
||||
"Annotations shown on the canvas" = "キャンバスに表示される注釈"
|
||||
"Any status" = "すべてのステータス"
|
||||
"Anyone on your network who has the invite link can join." = "招待リンクを持つネットワーク内の誰でも参加できる。"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "編集リンクを持つ人はファイルを変更でき、閲覧リンクでは表示のみ可能。リンクには秘密情報が含まれるため、パスワードと同様に扱うこと。リンクの追加と取り消しは `figcraft-server links` で行う。"
|
||||
"Appearance" = "外観"
|
||||
"Apply a component property" = "コンポーネントプロパティを適用"
|
||||
@@ -540,7 +539,6 @@ status = "draft — needs native review"
|
||||
"Horizontal hex" = "横向き六角形"
|
||||
"Horizontal padding" = "左右のパディング"
|
||||
"Horizontal spacing" = "水平方向の間隔"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "セッションのホストにはデスクトップアプリが必要。ここでは参加のみ可能。"
|
||||
"Hover a layer to see its properties" = "レイヤーにポインターを合わせるとプロパティを表示"
|
||||
"Hover other layers to measure the distance" = "他のレイヤーにポインターを合わせると距離を測定"
|
||||
"Hue" = "色相"
|
||||
@@ -591,7 +589,6 @@ status = "draft — needs native review"
|
||||
"Invite link copied" = "招待リンクをコピー済み"
|
||||
"It asks permission to:" = "次の権限を求めている:"
|
||||
"Join" = "参加"
|
||||
"Join a session…" = "セッションに参加…"
|
||||
"Join live session" = "ライブセッションに参加"
|
||||
"Join us on Discord" = "Discord に参加する"
|
||||
"Joined" = "参加済み"
|
||||
@@ -804,7 +801,6 @@ status = "draft — needs native review"
|
||||
"On this page" = "このページ"
|
||||
"Only the first changed layers are listed." = "変更されたレイヤーは最初の分のみ表示。"
|
||||
"Only the first {n} changed layers are listed." = "変更されたレイヤーは最初の {n} 個のみ表示。"
|
||||
"Only this computer can join (turn on network sharing to invite others)." = "このコンピューターのみ参加可能(他の人を招待するにはネットワーク共有をオンにする)。"
|
||||
"Only your threads" = "自分のスレッドのみ"
|
||||
"Opacity" = "不透明度"
|
||||
"Opacity & blend" = "不透明度とブレンド"
|
||||
@@ -825,7 +821,6 @@ status = "draft — needs native review"
|
||||
"Opened “{name}” in a new tab" = "「{name}」を新しいタブで開いた"
|
||||
"Open|comments" = "未解決"
|
||||
"Options" = "オプション"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "または、ファイルを FigCraft サーバーに置いて、編集用または閲覧用のリンクを共有。"
|
||||
"Orange" = "オレンジ"
|
||||
"Outline stroke" = "線のアウトライン化"
|
||||
"Outline text" = "テキストのアウトライン化"
|
||||
@@ -854,7 +849,6 @@ status = "draft — needs native review"
|
||||
"Paste a link (https://…)" = "リンクを貼り付け(https://…)"
|
||||
"Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け"
|
||||
"Paste here" = "ここに貼り付け"
|
||||
"Paste the invite link the host shared with you." = "ホストから共有された招待リンクを貼り付ける。"
|
||||
"Paste to replace" = "貼り付けて置換"
|
||||
"Pattern" = "パターン"
|
||||
"Pattern alignment" = "パターンの配置"
|
||||
@@ -912,7 +906,6 @@ status = "draft — needs native review"
|
||||
"Publish" = "公開"
|
||||
"Publish changes" = "変更を公開"
|
||||
"Publish to server" = "サーバーに公開"
|
||||
"Publish to server…" = "サーバーに公開…"
|
||||
"Published version {v}" = "バージョン {v} を公開済み"
|
||||
"Published version {v} to the server" = "バージョン {v} をサーバーに公開済み"
|
||||
"Published version {v}: save the file so other files can use it" = "バージョン {v} を公開済み:他のファイルで使えるようにファイルを保存"
|
||||
@@ -1075,7 +1068,6 @@ status = "draft — needs native review"
|
||||
"Shape builder" = "シェイプビルダー"
|
||||
"Shape tools" = "シェイプツール"
|
||||
"Share" = "共有"
|
||||
"Share on the local network" = "ローカルネットワークで共有"
|
||||
"Share: live session, export" = "共有:ライブセッション、書き出し"
|
||||
"Show" = "表示"
|
||||
"Show as grid" = "グリッドで表示"
|
||||
@@ -1134,7 +1126,6 @@ status = "draft — needs native review"
|
||||
"Start" = "開始"
|
||||
"Start ascending from / stop descending at" = "昇順の開始値 / 降順の終了値"
|
||||
"Start from a blank canvas" = "空白のキャンバスから始める"
|
||||
"Start live session" = "ライブセッションを開始"
|
||||
"Start on path" = "パス上の開始位置"
|
||||
"Start point" = "始点"
|
||||
"Status" = "ステータス"
|
||||
@@ -1316,7 +1307,6 @@ status = "draft — needs native review"
|
||||
"Yellow" = "黄"
|
||||
"Yesterday" = "昨日"
|
||||
"You" = "自分"
|
||||
"You'll get the host's file as a new tab and edit it together." = "ホストのファイルが新しいタブで開き、一緒に編集できる。"
|
||||
"You're spotlighting: everyone follows your view" = "スポットライト中:全員があなたのビューをフォロー"
|
||||
"Your copy of the file stays open" = "あなたのファイルのコピーは開いたまま"
|
||||
"Your name" = "あなたの名前"
|
||||
|
||||
@@ -93,7 +93,6 @@ status = "draft — needs native review"
|
||||
"Annotations" = "주석"
|
||||
"Annotations shown on the canvas" = "캔버스에 표시되는 주석"
|
||||
"Any status" = "모든 상태"
|
||||
"Anyone on your network who has the invite link can join." = "초대 링크가 있는 네트워크의 누구나 참여할 수 있음."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "편집 링크가 있으면 파일을 변경할 수 있고, 보기 링크로는 보기만 가능. 링크에는 비밀 정보가 담겨 있으므로 비밀번호처럼 공유하세요. 링크 추가와 취소는 `figcraft-server links`로 가능."
|
||||
"Appearance" = "모양"
|
||||
"Apply a component property" = "컴포넌트 속성 적용"
|
||||
@@ -539,7 +538,6 @@ status = "draft — needs native review"
|
||||
"Horizontal hex" = "가로 육각형"
|
||||
"Horizontal padding" = "가로 패딩"
|
||||
"Horizontal spacing" = "가로 간격"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "세션 호스팅에는 데스크톱 앱이 필요함. 여기서는 참여할 수 있음."
|
||||
"Hover a layer to see its properties" = "레이어에 마우스를 올려 속성 보기"
|
||||
"Hover other layers to measure the distance" = "다른 레이어에 마우스를 올려 거리 측정"
|
||||
"Hue" = "색조"
|
||||
@@ -590,7 +588,6 @@ status = "draft — needs native review"
|
||||
"Invite link copied" = "초대 링크 복사됨"
|
||||
"It asks permission to:" = "다음 권한을 요청함:"
|
||||
"Join" = "참여"
|
||||
"Join a session…" = "세션 참여…"
|
||||
"Join live session" = "라이브 세션 참여"
|
||||
"Join us on Discord" = "Discord에서 함께하기"
|
||||
"Joined" = "참여함"
|
||||
@@ -803,7 +800,6 @@ status = "draft — needs native review"
|
||||
"On this page" = "이 페이지"
|
||||
"Only the first changed layers are listed." = "변경된 레이어는 처음 일부만 표시됨."
|
||||
"Only the first {n} changed layers are listed." = "변경된 레이어는 처음 {n}개만 표시됨."
|
||||
"Only this computer can join (turn on network sharing to invite others)." = "이 컴퓨터만 참여할 수 있음(다른 사람을 초대하려면 네트워크 공유를 켜세요)."
|
||||
"Only your threads" = "내 스레드만"
|
||||
"Opacity" = "불투명도"
|
||||
"Opacity & blend" = "불투명도 및 혼합"
|
||||
@@ -824,7 +820,6 @@ status = "draft — needs native review"
|
||||
"Opened “{name}” in a new tab" = "\"{name}\"을(를) 새 탭에서 열었음"
|
||||
"Open|comments" = "미해결"
|
||||
"Options" = "옵션"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "또는 파일을 FigCraft 서버에 두고 편집용이나 보기용 링크를 공유하세요."
|
||||
"Orange" = "주황"
|
||||
"Outline stroke" = "선 윤곽선 만들기"
|
||||
"Outline text" = "텍스트 윤곽선 만들기"
|
||||
@@ -853,7 +848,6 @@ status = "draft — needs native review"
|
||||
"Paste a link (https://…)" = "링크 붙여 넣기(https://…)"
|
||||
"Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기"
|
||||
"Paste here" = "여기에 붙여 넣기"
|
||||
"Paste the invite link the host shared with you." = "호스트가 공유한 초대 링크를 붙여 넣으세요."
|
||||
"Paste to replace" = "붙여 넣어 바꾸기"
|
||||
"Pattern" = "패턴"
|
||||
"Pattern alignment" = "패턴 정렬"
|
||||
@@ -911,7 +905,6 @@ status = "draft — needs native review"
|
||||
"Publish" = "게시"
|
||||
"Publish changes" = "변경 사항 게시"
|
||||
"Publish to server" = "서버에 게시"
|
||||
"Publish to server…" = "서버에 게시…"
|
||||
"Published version {v}" = "버전 {v} 게시됨"
|
||||
"Published version {v} to the server" = "버전 {v}을(를) 서버에 게시함"
|
||||
"Published version {v}: save the file so other files can use it" = "버전 {v} 게시됨: 다른 파일에서 사용할 수 있도록 파일을 저장하세요"
|
||||
@@ -1074,7 +1067,6 @@ status = "draft — needs native review"
|
||||
"Shape builder" = "도형 빌더"
|
||||
"Shape tools" = "도형 도구"
|
||||
"Share" = "공유"
|
||||
"Share on the local network" = "로컬 네트워크에서 공유"
|
||||
"Share: live session, export" = "공유: 라이브 세션, 내보내기"
|
||||
"Show" = "표시"
|
||||
"Show as grid" = "그리드로 보기"
|
||||
@@ -1133,7 +1125,6 @@ status = "draft — needs native review"
|
||||
"Start" = "시작"
|
||||
"Start ascending from / stop descending at" = "오름차순 시작 값 / 내림차순 끝 값"
|
||||
"Start from a blank canvas" = "빈 캔버스로 시작"
|
||||
"Start live session" = "라이브 세션 시작"
|
||||
"Start on path" = "패스 시작 위치"
|
||||
"Start point" = "시작점"
|
||||
"Status" = "상태"
|
||||
@@ -1315,7 +1306,6 @@ status = "draft — needs native review"
|
||||
"Yellow" = "노랑"
|
||||
"Yesterday" = "어제"
|
||||
"You" = "나"
|
||||
"You'll get the host's file as a new tab and edit it together." = "호스트의 파일이 새 탭으로 열리고 함께 편집할 수 있음."
|
||||
"You're spotlighting: everyone follows your view" = "스포트라이트 중: 모두가 내 보기를 따라감"
|
||||
"Your copy of the file stays open" = "내 파일 사본은 열린 상태로 유지됨"
|
||||
"Your name" = "내 이름"
|
||||
|
||||
@@ -91,7 +91,6 @@ status = "draft — needs native review"
|
||||
"Annotations" = "Anotações"
|
||||
"Annotations shown on the canvas" = "Anotações exibidas na tela"
|
||||
"Any status" = "Qualquer status"
|
||||
"Anyone on your network who has the invite link can join." = "Qualquer pessoa na sua rede com o link de convite pode entrar."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Qualquer pessoa com o link de edição pode alterar o arquivo; o link de visualização só permite ver. Os links carregam seus segredos: compartilhe-os como senhas. `figcraft-server links` cria outros e os revoga."
|
||||
"Appearance" = "Aparência"
|
||||
"Apply a component property" = "Aplicar uma propriedade de componente"
|
||||
@@ -537,7 +536,6 @@ status = "draft — needs native review"
|
||||
"Horizontal hex" = "Hexágono horizontal"
|
||||
"Horizontal padding" = "Espaçamento interno horizontal"
|
||||
"Horizontal spacing" = "Espaçamento horizontal"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "Para hospedar uma sessão é preciso o app para desktop; aqui você pode entrar em uma."
|
||||
"Hover a layer to see its properties" = "Passe o cursor sobre uma camada para ver suas propriedades"
|
||||
"Hover other layers to measure the distance" = "Passe o cursor sobre outras camadas para medir a distância"
|
||||
"Hue" = "Matiz"
|
||||
@@ -588,7 +586,6 @@ status = "draft — needs native review"
|
||||
"Invite link copied" = "Link de convite copiado"
|
||||
"It asks permission to:" = "Ele pede permissão para:"
|
||||
"Join" = "Entrar"
|
||||
"Join a session…" = "Entrar em uma sessão…"
|
||||
"Join live session" = "Entrar na sessão ao vivo"
|
||||
"Join us on Discord" = "Junte-se a nós no Discord"
|
||||
"Joined" = "Conectado"
|
||||
@@ -801,7 +798,6 @@ status = "draft — needs native review"
|
||||
"On this page" = "Nesta página"
|
||||
"Only the first changed layers are listed." = "Somente as primeiras camadas alteradas são listadas."
|
||||
"Only the first {n} changed layers are listed." = "Somente as primeiras {n} camadas alteradas são listadas."
|
||||
"Only this computer can join (turn on network sharing to invite others)." = "Somente este computador pode entrar (ative o compartilhamento em rede para convidar outras pessoas)."
|
||||
"Only your threads" = "Somente suas conversas"
|
||||
"Opacity" = "Opacidade"
|
||||
"Opacity & blend" = "Opacidade e mesclagem"
|
||||
@@ -822,7 +818,6 @@ status = "draft — needs native review"
|
||||
"Opened “{name}” in a new tab" = "“{name}” aberto em uma nova aba"
|
||||
"Open|comments" = "Abertos"
|
||||
"Options" = "Opções"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "Ou mantenha o arquivo em um servidor FigCraft e compartilhe links para editá-lo ou visualizá-lo."
|
||||
"Orange" = "Laranja"
|
||||
"Outline stroke" = "Contornar traço"
|
||||
"Outline text" = "Converter texto em contornos"
|
||||
@@ -851,7 +846,6 @@ status = "draft — needs native review"
|
||||
"Paste a link (https://…)" = "Cole um link (https://…)"
|
||||
"Paste a team library link (a view link)" = "Cole o link de uma biblioteca de equipe (um link de visualização)"
|
||||
"Paste here" = "Colar aqui"
|
||||
"Paste the invite link the host shared with you." = "Cole o link de convite que o anfitrião compartilhou com você."
|
||||
"Paste to replace" = "Colar para substituir"
|
||||
"Pattern" = "Padrão"
|
||||
"Pattern alignment" = "Alinhamento do padrão"
|
||||
@@ -909,7 +903,6 @@ status = "draft — needs native review"
|
||||
"Publish" = "Publicar"
|
||||
"Publish changes" = "Publicar alterações"
|
||||
"Publish to server" = "Publicar no servidor"
|
||||
"Publish to server…" = "Publicar no servidor…"
|
||||
"Published version {v}" = "Versão {v} publicada"
|
||||
"Published version {v} to the server" = "Versão {v} publicada no servidor"
|
||||
"Published version {v}: save the file so other files can use it" = "Versão {v} publicada: salve o arquivo para que outros arquivos possam usá-la"
|
||||
@@ -1072,7 +1065,6 @@ status = "draft — needs native review"
|
||||
"Shape builder" = "Construtor de formas"
|
||||
"Shape tools" = "Ferramentas de forma"
|
||||
"Share" = "Compartilhar"
|
||||
"Share on the local network" = "Compartilhar na rede local"
|
||||
"Share: live session, export" = "Compartilhar: sessão ao vivo, exportar"
|
||||
"Show" = "Mostrar"
|
||||
"Show as grid" = "Mostrar em grade"
|
||||
@@ -1131,7 +1123,6 @@ status = "draft — needs native review"
|
||||
"Start" = "Início"
|
||||
"Start ascending from / stop descending at" = "Contar para cima a partir de / para baixo até"
|
||||
"Start from a blank canvas" = "Comece com uma tela em branco"
|
||||
"Start live session" = "Iniciar sessão ao vivo"
|
||||
"Start on path" = "Início no caminho"
|
||||
"Start point" = "Ponto inicial"
|
||||
"Status" = "Status"
|
||||
@@ -1313,7 +1304,6 @@ status = "draft — needs native review"
|
||||
"Yellow" = "Amarelo"
|
||||
"Yesterday" = "Ontem"
|
||||
"You" = "Você"
|
||||
"You'll get the host's file as a new tab and edit it together." = "Você vai receber o arquivo do anfitrião em uma nova aba para editarem juntos."
|
||||
"You're spotlighting: everyone follows your view" = "Você está em destaque: todos seguem sua visualização"
|
||||
"Your copy of the file stays open" = "Sua cópia do arquivo continua aberta"
|
||||
"Your name" = "Seu nome"
|
||||
|
||||
@@ -94,7 +94,6 @@ status = "draft — needs native review"
|
||||
"Annotations" = "标注"
|
||||
"Annotations shown on the canvas" = "画布上显示的标注"
|
||||
"Any status" = "任意状态"
|
||||
"Anyone on your network who has the invite link can join." = "你所在网络中持有邀请链接的任何人都可以加入。"
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "持有编辑链接的任何人都可以修改文件;查看链接只能查看。链接包含密钥,请像密码一样分享。`figcraft-server links` 可生成更多链接或将其撤销。"
|
||||
"Appearance" = "外观"
|
||||
"Apply a component property" = "应用组件属性"
|
||||
@@ -540,7 +539,6 @@ status = "draft — needs native review"
|
||||
"Horizontal hex" = "水平六边形"
|
||||
"Horizontal padding" = "水平内边距"
|
||||
"Horizontal spacing" = "水平间距"
|
||||
"Hosting a session needs the desktop app; here you can join one." = "主持会话需要桌面应用;在这里可以加入会话。"
|
||||
"Hover a layer to see its properties" = "悬停在图层上查看其属性"
|
||||
"Hover other layers to measure the distance" = "悬停在其他图层上测量距离"
|
||||
"Hue" = "色相"
|
||||
@@ -591,7 +589,6 @@ status = "draft — needs native review"
|
||||
"Invite link copied" = "已复制邀请链接"
|
||||
"It asks permission to:" = "它请求以下权限:"
|
||||
"Join" = "加入"
|
||||
"Join a session…" = "加入会话…"
|
||||
"Join live session" = "加入实时会话"
|
||||
"Join us on Discord" = "加入我们的 Discord"
|
||||
"Joined" = "已加入"
|
||||
@@ -804,7 +801,6 @@ status = "draft — needs native review"
|
||||
"On this page" = "在此页面"
|
||||
"Only the first changed layers are listed." = "仅列出前几个已更改的图层。"
|
||||
"Only the first {n} changed layers are listed." = "仅列出前 {n} 个已更改的图层。"
|
||||
"Only this computer can join (turn on network sharing to invite others)." = "只有本机可以加入(开启网络共享以邀请他人)。"
|
||||
"Only your threads" = "仅你的讨论"
|
||||
"Opacity" = "不透明度"
|
||||
"Opacity & blend" = "不透明度与混合"
|
||||
@@ -825,7 +821,6 @@ status = "draft — needs native review"
|
||||
"Opened “{name}” in a new tab" = "已在新标签页中打开“{name}”"
|
||||
"Open|comments" = "未解决"
|
||||
"Options" = "选项"
|
||||
"Or keep the file on a FigCraft server and share links to edit or to view it." = "或者将文件保存在 FigCraft 服务器上,并分享编辑或查看链接。"
|
||||
"Orange" = "橙"
|
||||
"Outline stroke" = "轮廓化描边"
|
||||
"Outline text" = "轮廓化文本"
|
||||
@@ -854,7 +849,6 @@ status = "draft — needs native review"
|
||||
"Paste a link (https://…)" = "粘贴链接(https://…)"
|
||||
"Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)"
|
||||
"Paste here" = "粘贴到此处"
|
||||
"Paste the invite link the host shared with you." = "粘贴主持人分享给你的邀请链接。"
|
||||
"Paste to replace" = "粘贴以替换"
|
||||
"Pattern" = "图案"
|
||||
"Pattern alignment" = "图案对齐"
|
||||
@@ -912,7 +906,6 @@ status = "draft — needs native review"
|
||||
"Publish" = "发布"
|
||||
"Publish changes" = "发布更改"
|
||||
"Publish to server" = "发布到服务器"
|
||||
"Publish to server…" = "发布到服务器…"
|
||||
"Published version {v}" = "已发布版本 {v}"
|
||||
"Published version {v} to the server" = "已将版本 {v} 发布到服务器"
|
||||
"Published version {v}: save the file so other files can use it" = "已发布版本 {v}:请保存文件,以便其他文件使用"
|
||||
@@ -1075,7 +1068,6 @@ status = "draft — needs native review"
|
||||
"Shape builder" = "形状生成器"
|
||||
"Shape tools" = "形状工具"
|
||||
"Share" = "分享"
|
||||
"Share on the local network" = "在局域网中共享"
|
||||
"Share: live session, export" = "分享:实时会话、导出"
|
||||
"Show" = "显示"
|
||||
"Show as grid" = "以网格显示"
|
||||
@@ -1134,7 +1126,6 @@ status = "draft — needs native review"
|
||||
"Start" = "起点"
|
||||
"Start ascending from / stop descending at" = "升序起点 / 降序终点"
|
||||
"Start from a blank canvas" = "从空白画布开始"
|
||||
"Start live session" = "开始实时会话"
|
||||
"Start on path" = "路径起点"
|
||||
"Start point" = "起点"
|
||||
"Status" = "状态"
|
||||
@@ -1316,7 +1307,6 @@ status = "draft — needs native review"
|
||||
"Yellow" = "黄"
|
||||
"Yesterday" = "昨天"
|
||||
"You" = "你"
|
||||
"You'll get the host's file as a new tab and edit it together." = "主持人的文件将在新标签页中打开,你们可以一起编辑。"
|
||||
"You're spotlighting: everyone follows your view" = "你正在聚焦:所有人都在跟随你的视图"
|
||||
"Your copy of the file stays open" = "你的文件副本保持打开"
|
||||
"Your name" = "你的名字"
|
||||
|
||||
@@ -827,6 +827,14 @@ pub fn menu_button(resp: &Response, label: &str, expanded: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A button that shows or hides what is under it, `expanded` while that shows.
|
||||
pub fn disclosure(resp: &Response, label: &str, expanded: bool) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::Button);
|
||||
n.set_expanded(expanded);
|
||||
});
|
||||
}
|
||||
|
||||
/// A toggle button (a tool, a segment), pressed when `on`.
|
||||
pub fn toggle(resp: &Response, tip: &str, on: bool) {
|
||||
let (name, key) = split_tip(tip);
|
||||
|
||||
@@ -186,12 +186,21 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Server to publish to"));
|
||||
// Suggested by Share > Publish to server; no key here (preferences are saved as text).
|
||||
if let Some(url) = crate::widgets::text_field(ui, egui::Id::new("pref_publish_server"), &prefs.publish_server, 220.0, "wss://…") {
|
||||
ui.label(tr("Server for online sharing"));
|
||||
// Where Share ▸ Online keeps files and join codes are asked; no key here
|
||||
// (preferences are saved as text).
|
||||
let name = tr("Server for online sharing");
|
||||
if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::new("pref_publish_server"), &prefs.publish_server, 220.0, name) {
|
||||
app.run("pref.publishServer", json!({"url": url}));
|
||||
}
|
||||
let usual = prefs.publish_server == figcraft_engine::live::DEFAULT_SERVER;
|
||||
if crate::widgets::button(ui, tr("Reset"), false, !usual).on_hover_text(tr("Back to the FigCraft server")).clicked() {
|
||||
app.run("pref.publishServer", json!({}));
|
||||
}
|
||||
});
|
||||
if prefs.publish_server.is_empty() {
|
||||
crate::widgets::label(ui, tr("Online sharing is off: files are shared on the local network only."), t.text_secondary);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Nudge amount"));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The home screen: new file, open, recent files, templates.
|
||||
//! The home screen: new file, open, join with a code, recent files, templates.
|
||||
|
||||
use egui::{Align2, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
@@ -71,8 +71,29 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
if open {
|
||||
app.open_dialog();
|
||||
}
|
||||
// Join a file someone shared: the join field, and what the join is doing.
|
||||
let join = Rect::from_min_size(pos2(main.min.x, cy + 96.0), vec2(main.width().min(540.0), 74.0));
|
||||
ui.painter().text(join.min, Align2::LEFT_TOP, tr("Join with a code"), semibold(12.0), t.text_secondary);
|
||||
let field = Rect::from_min_max(pos2(join.min.x, join.min.y + 24.0), join.max);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(field).layout(egui::Layout::top_down(egui::Align::Min)), |ui| {
|
||||
if let Some(session) = crate::live::join_row(app, ui, 260.0) {
|
||||
app.ui.live.home_join = Some(session);
|
||||
}
|
||||
if let Some(session) = app.ui.live.home_join {
|
||||
crate::live::join_status(app, ui, &t, session);
|
||||
}
|
||||
});
|
||||
// The shared file opened in a tab of its own: show it. (A join that was left is forgotten.)
|
||||
match app.ui.live.home_join.map(|id| app.session.live.get(id).map(figcraft_engine::live::Live::syncing)) {
|
||||
Some(Some(true)) => {
|
||||
app.ui.live.home_join = None;
|
||||
app.ui.home = false;
|
||||
}
|
||||
Some(None) => app.ui.live.home_join = None,
|
||||
_ => {}
|
||||
}
|
||||
// Open documents and recent files.
|
||||
let mut y = cy + 108.0;
|
||||
let mut y = join.max.y + 22.0;
|
||||
ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, tr("Open files"), semibold(12.0), t.text_secondary);
|
||||
y += 28.0;
|
||||
let mut switch = None;
|
||||
|
||||
@@ -94,6 +94,8 @@ mod live_join_tests;
|
||||
#[cfg(test)]
|
||||
mod live_server_tests;
|
||||
#[cfg(test)]
|
||||
mod live_share_tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod live_text_tests;
|
||||
|
||||
+261
-64
@@ -1,8 +1,9 @@
|
||||
//! Live sessions in the window: other people's cursors (smoothly following their pointer, with a
|
||||
//! name tag) and selections on the canvas (their edits arrive through the engine's sync), their avatars at the top of the right panel (click one
|
||||
//! to follow their view), the Share popover (start a session, the invite link, stop; the server,
|
||||
//! file and access of a session you joined; a published file's links), the Join dialog and the
|
||||
//! Publish to server dialog. Everything acts through the `collab.*` commands; the state is the engine's:
|
||||
//! to follow their view), the Share popover (share the file online or on the local network and
|
||||
//! show its join code; join with a code; the server, file and access of a session you joined; a
|
||||
//! published file's links), the join field (one component: the popover, the home screen and the
|
||||
//! Join dialog), and the Publish to server dialog. Everything acts through the `collab.*` commands; the state is the engine's:
|
||||
//! sessions are per document, and a window shows the session of the file in front
|
||||
//! (`Session::live`), so another tab shows its own session or the way to start one. The network
|
||||
//! wakes the UI when something arrives, and cursors only ask for frames while they glide, so an
|
||||
@@ -15,7 +16,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Signal, Status, initials, parse_color, presence_page};
|
||||
use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, Via, initials, parse_color, presence_page};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
@@ -89,8 +90,21 @@ pub struct LiveUi {
|
||||
pub join: Option<String>,
|
||||
/// Put the keyboard in the Join dialog's field on its next frame.
|
||||
focus_join: bool,
|
||||
/// Start sessions on the local network (else this machine only).
|
||||
pub lan: bool,
|
||||
/// Share on the local network (else online, through the server in Preferences).
|
||||
pub local: bool,
|
||||
/// The Share popover's Advanced part shows; what it sets for a local network share: only
|
||||
/// this computer can join, and the port (empty: the usual one).
|
||||
pub advanced: bool,
|
||||
pub this_computer: bool,
|
||||
pub port: String,
|
||||
/// What is typed in the join field of the Share popover and the home screen.
|
||||
pub code: String,
|
||||
/// The join started from the home screen: what it is doing shows under the field, and its
|
||||
/// file's tab shows when it opens.
|
||||
pub home_join: Option<SessionId>,
|
||||
/// What the server for online sharing said about its hosting (`collab.serverInfo`), and the
|
||||
/// address asked.
|
||||
pub server_info: Option<(String, Value)>,
|
||||
/// The Publish to server dialog's fields (open when `Some`).
|
||||
pub publish: Option<PublishForm>,
|
||||
/// Put the keyboard in the Publish dialog's first field on its next frame.
|
||||
@@ -536,14 +550,22 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tr("Live session")).font(semibold(12.0)));
|
||||
let status = app.session.live().status.clone();
|
||||
let role = app.session.live().role();
|
||||
let others = app.session.live().peers.len();
|
||||
let published = app.session.live().published.is_some();
|
||||
let share = app.session.live().share.clone().filter(|_| status.active());
|
||||
let title = if status.active() { tr("Live session") } else { tr("Share this file") };
|
||||
ui.label(egui::RichText::new(title).font(semibold(12.0)));
|
||||
// Connecting with a tab already chosen and nobody's welcome yet: the upload.
|
||||
let publishing = status == Status::Connecting && role.is_none() && app.session.live().doc_uid.is_some();
|
||||
let (line, col) = match (&status, role) {
|
||||
(Status::Off, _) => (String::new(), t.text_secondary),
|
||||
(Status::Live, _) if share.as_ref().is_some_and(|s| s.via == Via::Online) => {
|
||||
(trf("Sharing online · {people}", &[("people", &people_count(others))]), t.success)
|
||||
}
|
||||
(Status::Live, _) if share.is_some() => (trf("Sharing on your network · {people}", &[("people", &people_count(others))]), t.success),
|
||||
(Status::Connecting, _) if share.is_some() => (tr("Starting to share…").to_string(), t.text_secondary),
|
||||
(Status::Live, Some(Role::Host)) => (trf("Live · {people}", &[("people", &people_count(others))]), t.success),
|
||||
(Status::Live, _) if published => (trf("Published · {people}", &[("people", &people_count(others))]), t.success),
|
||||
(Status::Connecting, _) if publishing => (tr("Publishing…").to_string(), t.text_secondary),
|
||||
@@ -557,10 +579,13 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
|
||||
(Status::Failed { .. }, _) => (status.label(), t.danger),
|
||||
_ => (tr(&status.label()).to_string(), t.text_secondary),
|
||||
};
|
||||
widgets::label(ui, &line, col);
|
||||
match (&status, role) {
|
||||
(Status::Off | Status::Failed { .. }, _) => start_ui(app, ui, &t),
|
||||
(Status::Live, Some(Role::Host)) => host_ui(app, ui),
|
||||
if !line.is_empty() {
|
||||
widgets::label(ui, &line, col);
|
||||
}
|
||||
match (&status, role, &share) {
|
||||
(Status::Off | Status::Failed { .. }, ..) => start_ui(app, ui, &t),
|
||||
(_, _, Some(share)) => share_ui(app, ui, &t, share),
|
||||
(Status::Live, Some(Role::Host), _) => host_ui(app, ui),
|
||||
_ => guest_ui(app, ui, &t),
|
||||
}
|
||||
});
|
||||
@@ -579,7 +604,84 @@ fn people_count(others: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Not in a session: your name, where to share, start; or join one.
|
||||
/// The host a server address names (`wss://share.example/` is `share.example`).
|
||||
fn server_host(server: &str) -> &str {
|
||||
server.split_once("://").map_or(server, |(_, rest)| rest).trim_end_matches('/')
|
||||
}
|
||||
|
||||
/// What a server says about its hosting (`collab.serverInfo`): who runs it, its terms, how long
|
||||
/// files nobody opens are kept, and that it can read them.
|
||||
fn hosting_lines(ui: &mut Ui, t: &Tokens, info: &Value) {
|
||||
host_line(ui, t, info["operator"].as_str().unwrap_or_default(), info["terms"].as_str().unwrap_or_default());
|
||||
if let Some(days) = info["idleDays"].as_f64() {
|
||||
let n = days.ceil().max(1.0) as usize;
|
||||
widgets::label(ui, &trn("Files nobody opens for {n} days are deleted.", n, &[]), t.text_secondary);
|
||||
}
|
||||
widgets::label(ui, tr(PLAIN_NOTE), t.text_secondary);
|
||||
}
|
||||
|
||||
/// Sharing online: where the file goes, and on request what that server says about its hosting.
|
||||
fn online_note(app: &mut FigApp, ui: &mut Ui, t: &Tokens, server: &str) {
|
||||
widgets::label(
|
||||
ui,
|
||||
&trf("People join from anywhere with a code. The file is kept on {server} while you share it.", &[("server", &server_host(server))]),
|
||||
t.text_secondary,
|
||||
);
|
||||
match app.ui.live.server_info.as_ref().filter(|(at, _)| at == server) {
|
||||
Some((_, info)) if info["open"] == true => hosting_lines(ui, t, info),
|
||||
Some(_) => widgets::label(ui, tr("This server takes files only with its key: see Advanced."), t.danger),
|
||||
None if app.session.waiting_for(SERVER_INFO) => widgets::label(ui, tr("Asking the server…"), t.text_secondary),
|
||||
None => {
|
||||
let about = egui::Link::new(egui::RichText::new(tr("About this server")).font(regular(11.0)));
|
||||
if ui.add(about).on_hover_text(tr("Who runs it, its terms and how long it keeps files")).clicked() {
|
||||
ask_server(app, &ui.ctx().clone(), server.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The join field with its Join button, on one row: what is typed stays while the popover or the
|
||||
/// home screen is closed. The session of the join that started, if one did.
|
||||
pub fn join_row(app: &mut FigApp, ui: &mut Ui, width: f32) -> Option<SessionId> {
|
||||
let mut typed = std::mem::take(&mut app.ui.live.code);
|
||||
let joined = ui.horizontal(|ui| join_field(app, ui, &mut typed, egui::Id::new("live_join_code"), width, false)).inner;
|
||||
app.ui.live.code = typed;
|
||||
joined
|
||||
}
|
||||
|
||||
/// The one way to join: a field for a join code or an invite link (pasted or typed; spaces,
|
||||
/// dashes and case don't matter in a code) and a Join button; Enter joins too. The session of the
|
||||
/// join that started (the field is cleared); what can't be joined says why and stays to be fixed.
|
||||
pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::Id, width: f32, focus: bool) -> Option<SessionId> {
|
||||
let resp = ui.add(egui::TextEdit::singleline(typed).hint_text(tr("Code or invite link")).desired_width(width).id(id));
|
||||
// It may hold a link with its secret: it isn't read out.
|
||||
a11y::text(&resp, tr("Join code or invite link"), "");
|
||||
if focus {
|
||||
resp.request_focus();
|
||||
}
|
||||
let filled = !typed.trim().is_empty();
|
||||
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let go = widgets::button(ui, tr("Join"), true, filled).clicked() || enter;
|
||||
let joined = if go && filled { app.run("collab.join", json!({"code": typed.trim()})) } else { None };
|
||||
let session = joined.and_then(|j| j["session"].as_u64());
|
||||
if session.is_some() {
|
||||
typed.clear();
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
/// What join `session` is doing, under the field it was started from (the home screen): on its
|
||||
/// way, or why it failed.
|
||||
pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) {
|
||||
match app.session.live.get(session).map(|l| &l.status) {
|
||||
Some(Status::Connecting) => widgets::label(ui, tr("Joining…"), t.text_secondary),
|
||||
Some(Status::Failed { reason }) => widgets::label(ui, reason, t.danger),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Not in a session: your name, how to share (online or on the local network), start; or join
|
||||
/// one with a code. Everything else is under Advanced.
|
||||
fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, tr("Your name"), t.text);
|
||||
@@ -589,40 +691,143 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
if !app.session.live.can_host() {
|
||||
widgets::label(ui, tr("Hosting a session needs the desktop app; here you can join one."), t.text_secondary);
|
||||
if widgets::button(ui, tr("Join a session…"), true, true).clicked() {
|
||||
open_join(app, "");
|
||||
ui.close();
|
||||
}
|
||||
widgets::label(ui, tr("Sharing a file needs the desktop app; here you can join one."), t.text_secondary);
|
||||
widgets::label(ui, tr("Join with a code"), t.text);
|
||||
join_row(app, ui, JOIN_WIDTH);
|
||||
widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary);
|
||||
return;
|
||||
}
|
||||
if widgets::checkbox(ui, app.ui.live.lan, tr("Share on the local network")) {
|
||||
app.ui.live.lan = !app.ui.live.lan;
|
||||
// Without a server in Preferences only the local network is left.
|
||||
let server = app.session.prefs.publish_server.trim().to_string();
|
||||
let local = app.ui.live.local || server.is_empty();
|
||||
if server.is_empty() {
|
||||
widgets::label(ui, tr("Sharing on the local network (online sharing is off: no server is set in Preferences)."), t.text_secondary);
|
||||
} else if let Some(i) = widgets::segmented_text(ui, &[tr("Online"), tr("Local network")], usize::from(local)) {
|
||||
app.ui.live.local = i == 1;
|
||||
}
|
||||
let reach = if app.ui.live.lan {
|
||||
tr("Anyone on your network who has the invite link can join.")
|
||||
if local {
|
||||
widgets::label(ui, tr("People on your network join with a code. Nothing goes through a server."), t.text_secondary);
|
||||
} else {
|
||||
tr("Only this computer can join (turn on network sharing to invite others).")
|
||||
};
|
||||
widgets::label(ui, reach, t.text_secondary);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::button(ui, tr("Start live session"), true, true).clicked() {
|
||||
let bind = if app.ui.live.lan { "lan" } else { "local" };
|
||||
app.run("collab.host", json!({"bind": bind}));
|
||||
online_note(app, ui, t, &server);
|
||||
}
|
||||
if widgets::button(ui, tr("Start sharing"), true, true).clicked() {
|
||||
let mut params = json!({"via": if local { "local" } else { "online" }});
|
||||
if local && app.ui.live.this_computer {
|
||||
params["bind"] = json!("local");
|
||||
}
|
||||
if widgets::button(ui, tr("Join a session…"), false, true).clicked() {
|
||||
open_join(app, "");
|
||||
if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::<u16>()) {
|
||||
params["port"] = json!(port);
|
||||
}
|
||||
app.run("collab.share", params);
|
||||
}
|
||||
ui.separator();
|
||||
widgets::label(ui, tr("Join with a code"), t.text);
|
||||
join_row(app, ui, JOIN_WIDTH);
|
||||
if widgets::disclosure(ui, tr("Advanced"), app.ui.live.advanced) {
|
||||
app.ui.live.advanced = !app.ui.live.advanced;
|
||||
}
|
||||
if !app.ui.live.advanced {
|
||||
return;
|
||||
}
|
||||
if local {
|
||||
if widgets::checkbox(ui, app.ui.live.this_computer, tr("This computer only")) {
|
||||
app.ui.live.this_computer = !app.ui.live.this_computer;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, tr("Port"), t.text);
|
||||
if let Some(port) = widgets::text_field_named(ui, egui::Id::new("live_port"), &app.ui.live.port, 70.0, tr("Port")) {
|
||||
// Digits only; empty: the usual port, or any free one.
|
||||
app.ui.live.port = port.trim().parse::<u16>().map(|p| p.to_string()).unwrap_or_default();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary);
|
||||
if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() {
|
||||
open_publish(app);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
widgets::label(ui, tr("Or keep the file on a FigCraft server and share links to edit or to view it."), t.text_secondary);
|
||||
if widgets::button(ui, tr("Publish to server…"), false, true).clicked() {
|
||||
open_publish(app);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The join field's width in the Share popover (the Join button fits beside it).
|
||||
const JOIN_WIDTH: f32 = 196.0;
|
||||
|
||||
/// What sharing without a join code falls back to.
|
||||
const NO_CODE_NOTE: &str =
|
||||
tr_noop("No join code here (the server is older than join codes, or this address can't be put in one): share the link instead.");
|
||||
|
||||
/// Sharing with a join code: the code, large; copying it or the link; what it lets people do;
|
||||
/// who is here; and stop.
|
||||
fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
||||
let live = app.session.live();
|
||||
let online = share.via == Via::Online;
|
||||
let stop = |app: &mut FigApp, ui: &mut Ui| {
|
||||
let hint = if online {
|
||||
tr("Removes the file from the server; everyone keeps their copy")
|
||||
} else {
|
||||
tr("Ends the session; everyone keeps their copy")
|
||||
};
|
||||
if widgets::button(ui, tr("Stop sharing"), false, true).on_hover_text(hint).clicked() {
|
||||
app.run("collab.stop", json!({}));
|
||||
}
|
||||
};
|
||||
if live.status != Status::Live {
|
||||
return stop(app, ui);
|
||||
}
|
||||
let view = share.access == Access::View;
|
||||
let link = match (&live.published, &live.info) {
|
||||
(Some(p), _) => if view { &p.view } else { &p.edit }.clone(),
|
||||
(None, Some(i)) => i.link.clone(),
|
||||
(None, None) => String::new(),
|
||||
};
|
||||
let me = live.info.as_ref().map(|i| i.name.clone()).unwrap_or_default();
|
||||
let names: Vec<String> = std::iter::once(trf("{name} (you)", &[("name", &me)])).chain(live.peers.values().map(|p| p.name.clone())).collect();
|
||||
let hosting = live.hosting.clone();
|
||||
match share.code() {
|
||||
Some(code) => {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.label(egui::RichText::new(&code).font(egui::FontId::monospace(if online { 24.0 } else { 15.0 })).color(t.text))
|
||||
.on_hover_text(tr("Others type this under Share ▸ Join with a code"));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::button(ui, tr("Copy code"), true, true).clicked() {
|
||||
app.services.clipboard_set_text(&code);
|
||||
app.toast(tr("Join code copied"));
|
||||
}
|
||||
if widgets::button(ui, tr("Copy link"), false, true).on_hover_text(tr("An invite link that opens FigCraft and joins")).clicked() {
|
||||
app.services.clipboard_set_text(&link);
|
||||
app.toast(tr("Invite link copied"));
|
||||
}
|
||||
});
|
||||
}
|
||||
None => {
|
||||
widgets::label(ui, tr(NO_CODE_NOTE), t.text_secondary);
|
||||
link_rows(app, ui, &[(tr("Invite link"), &link, tr("Invite link copied"))]);
|
||||
}
|
||||
}
|
||||
// A server has a link (and a code) for looking only; a session this app hosts doesn't.
|
||||
if online
|
||||
&& let Some(i) = widgets::segmented_text(ui, &[tr("Can edit"), tr("Can view")], usize::from(view))
|
||||
&& (i == 1) != view
|
||||
{
|
||||
app.run("collab.share", json!({"access": if i == 1 { "view" } else { "edit" }}));
|
||||
}
|
||||
let who = match (online, view) {
|
||||
(true, true) => tr("Anyone with this code can look at the file, not change it."),
|
||||
(true, false) => tr("Anyone with this code can edit the file."),
|
||||
(false, _) => tr("Anyone on your network with this code can edit the file, while this app stays open."),
|
||||
};
|
||||
widgets::label(ui, who, t.text_secondary);
|
||||
let here = trf("Here: {names}", &[("names", &names.join(", "))]);
|
||||
ui.label(egui::RichText::new(widgets::elide(ui, &here, ui.available_width())).font(regular(11.0)).color(t.text_secondary)).on_hover_text(here);
|
||||
if let Some(h) = &hosting {
|
||||
host_line(ui, t, &h.operator, &h.terms);
|
||||
expiry_line(ui, t, h.expires);
|
||||
}
|
||||
stop(app, ui);
|
||||
}
|
||||
|
||||
/// Hosting: the invite link and URL to copy, and stop.
|
||||
fn host_ui(app: &mut FigApp, ui: &mut Ui) {
|
||||
let Some(info) = app.session.live().info.clone() else { return };
|
||||
@@ -774,7 +979,7 @@ pub fn dialog_keys(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// File ▸ Join live session: paste an invite link.
|
||||
/// File ▸ Join live session: type a join code or paste an invite link.
|
||||
pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
// The editor takes Escape before its shortcuts; the home screen has none.
|
||||
dialog_keys(app, ctx);
|
||||
@@ -786,25 +991,17 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let focus = std::mem::take(&mut app.ui.live.focus_join);
|
||||
crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::new("Join live session")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
widgets::label(ui, tr("Paste the invite link the host shared with you."), t.text);
|
||||
let resp = ui
|
||||
.add(egui::TextEdit::singleline(&mut link).hint_text("figcraft://join?host=…").desired_width(380.0).id(egui::Id::new("live_join_link")));
|
||||
a11y::text(&resp, tr("Invite link"), "");
|
||||
if focus {
|
||||
resp.request_focus();
|
||||
}
|
||||
go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
widgets::label(ui, tr("You'll get the host's file as a new tab and edit it together."), t.text_secondary);
|
||||
widgets::label(ui, tr("Type the join code you were given, or paste an invite link."), t.text);
|
||||
ui.horizontal(|ui| {
|
||||
go = join_field(app, ui, &mut link, egui::Id::new("live_join_link"), 300.0, focus).is_some();
|
||||
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
|
||||
});
|
||||
widgets::label(ui, tr("You'll get the shared file as a new tab and edit it together."), t.text_secondary);
|
||||
if !app.session.live.can_host() {
|
||||
widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
go |= widgets::button(ui, tr("Join"), true, !link.trim().is_empty()).clicked();
|
||||
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
|
||||
});
|
||||
});
|
||||
if go && !link.trim().is_empty() && app.run("collab.join", json!({"link": link.trim()})).is_some() {
|
||||
if go {
|
||||
return;
|
||||
}
|
||||
if open && !cancel {
|
||||
@@ -812,16 +1009,15 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Publish to server dialog for the open file (the last address used this run in it,
|
||||
/// else the one in Preferences).
|
||||
/// Open the Publish to server dialog for the open file, with the last address used this run in
|
||||
/// it (the server in Preferences is what Share ▸ Online uses: this dialog is for another one).
|
||||
pub fn open_publish(app: &mut FigApp) {
|
||||
if !app.session.live.can_host() {
|
||||
app.toast(tr("Publishing to a server needs the desktop app; in the browser you can join a server's files"));
|
||||
return;
|
||||
}
|
||||
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default();
|
||||
let server = if app.ui.live.last_server.is_empty() { app.session.prefs.publish_server.clone() } else { app.ui.live.last_server.clone() };
|
||||
app.ui.live.publish = Some(PublishForm { server, name, info: None });
|
||||
app.ui.live.publish = Some(PublishForm { server: app.ui.live.last_server.clone(), name, info: None });
|
||||
app.ui.live.focus_publish = true;
|
||||
}
|
||||
|
||||
@@ -831,7 +1027,7 @@ fn has_key(server: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Ask the server at `server` about its hosting in the background; the answer goes into the
|
||||
/// Publish dialog while it still shows that address.
|
||||
/// Publish dialog while it still shows that address, and is kept for the Share popover.
|
||||
fn ask_server(app: &mut FigApp, ctx: &egui::Context, server: String) {
|
||||
app.run_background(
|
||||
ctx,
|
||||
@@ -839,8 +1035,9 @@ fn ask_server(app: &mut FigApp, ctx: &egui::Context, server: String) {
|
||||
json!({"server": server}),
|
||||
Box::new(move |app, _, v| {
|
||||
if let Some(f) = app.ui.live.publish.as_mut().filter(|f| f.server.trim() == server) {
|
||||
f.info = Some((server, v));
|
||||
f.info = Some((server.clone(), v.clone()));
|
||||
}
|
||||
app.ui.live.server_info = Some((server, v));
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -894,12 +1091,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
_ if asking => widgets::label(ui, tr("Asking the server…"), t.text_secondary),
|
||||
Some(v) if open_server => {
|
||||
widgets::label(ui, tr("This server is open to the public: no key needed."), t.success);
|
||||
host_line(ui, &t, v["operator"].as_str().unwrap_or_default(), v["terms"].as_str().unwrap_or_default());
|
||||
if let Some(days) = v["idleDays"].as_f64() {
|
||||
let n = days.ceil().max(1.0) as usize;
|
||||
widgets::label(ui, &trn("Files nobody opens for {n} days are deleted.", n, &[]), t.text_secondary);
|
||||
}
|
||||
widgets::label(ui, tr(PLAIN_NOTE), t.text_secondary);
|
||||
hosting_lines(ui, &t, v);
|
||||
}
|
||||
Some(_) => widgets::label(
|
||||
ui,
|
||||
@@ -997,6 +1189,11 @@ pub fn inspect(app: &FigApp) -> serde_json::Value {
|
||||
"chatBox": app.ui.live.chat.as_ref().map(|c| json!({"text": c.text, "editing": c.editing})),
|
||||
"cursorsShown": app.ui.live.shown(),
|
||||
"joinDialog": app.ui.live.join.is_some(),
|
||||
"joinField": app.ui.live.code,
|
||||
"code": l.share.as_ref().and_then(Share::code),
|
||||
"share": l.share.as_ref().map(|s| json!({"via": s.via, "access": s.access.id()})),
|
||||
"local": app.ui.live.local,
|
||||
"advanced": app.ui.live.advanced,
|
||||
"publishDialog": app.ui.live.publish.is_some(),
|
||||
"publishInfo": app.ui.live.publish.as_ref().and_then(|f| f.info.as_ref().map(|(_, v)| v.clone())),
|
||||
"ownerDialog": app.ui.live.owner.and_then(|id| app.session.live.get(id)).and_then(|l| l.published.as_ref()).is_some_and(|p| p.owner.is_some()),
|
||||
|
||||
@@ -52,6 +52,14 @@ fn harness() -> (Harness, Queue) {
|
||||
(h, queue)
|
||||
}
|
||||
|
||||
/// Share the file in front on the local network from its popover (which is open).
|
||||
fn start_sharing(h: &mut Harness, shapes: &[egui::Shape]) -> Vec<egui::Shape> {
|
||||
h.click(text_center(shapes, "Local network"));
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Start sharing"));
|
||||
h.frames(2)
|
||||
}
|
||||
|
||||
fn live(h: &Harness) -> Value {
|
||||
h.app.inspect()["live"].clone()
|
||||
}
|
||||
@@ -72,10 +80,9 @@ fn the_share_popover_is_the_session_of_the_tab_in_front() {
|
||||
let shapes = h.frames(1);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Not in a live session"));
|
||||
h.click(text_center(&shapes, "Start live session"));
|
||||
let shapes = h.frames(2);
|
||||
for t in ["Live · nobody else here yet", "Invite link", "Stop session"] {
|
||||
assert!(has_text(&shapes, "Share this file"));
|
||||
let shapes = start_sharing(&mut h, &shapes);
|
||||
for t in ["Sharing on your network · nobody else here yet", "Invite link", "Stop sharing"] {
|
||||
assert!(has_text(&shapes, t), "hosting shows {t:?}");
|
||||
}
|
||||
queue.lock().unwrap().push((1, LiveEvent::PeerJoined(hana())));
|
||||
@@ -92,16 +99,15 @@ fn the_share_popover_is_the_session_of_the_tab_in_front() {
|
||||
(&json!("off"), &Value::Null, &Value::Null, &json!(0), &json!(1))
|
||||
);
|
||||
let shapes = h.frames(1);
|
||||
for t in ["Not in a live session", "Start live session", "Join a session…", "Publish to server…"] {
|
||||
for t in ["Share this file", "Start sharing", "Join with a code", "Advanced"] {
|
||||
assert!(has_text(&shapes, t), "the new file's popover shows {t:?}");
|
||||
}
|
||||
for t in ["Invite link", "Stop session", "Live · 1 people joined", "Hana"] {
|
||||
for t in ["Invite link", "Stop sharing", "Sharing on your network · 1 person joined", "Hana"] {
|
||||
assert!(!has_text(&shapes, t), "nothing of the other file's session: {t:?}");
|
||||
}
|
||||
// Start one here too: both run, each tab shows its own.
|
||||
h.click(text_center(&shapes, "Start live session"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Live · nobody else here yet") && has_text(&shapes, "Stop session"));
|
||||
let shapes = start_sharing(&mut h, &shapes);
|
||||
assert!(has_text(&shapes, "Sharing on your network · nobody else here yet") && has_text(&shapes, "Stop sharing"));
|
||||
assert_eq!((&live(&h)["session"], &live(&h)["peers"], &live(&h)["sessions"]), (&json!(2), &json!(0), &json!(2)));
|
||||
assert_eq!(h.app.session.live().info.as_ref().unwrap().address, "127.0.0.1:47901");
|
||||
run(&mut h, "doc.switch", json!({"index": 0}));
|
||||
@@ -109,9 +115,9 @@ fn the_share_popover_is_the_session_of_the_tab_in_front() {
|
||||
assert_eq!(h.app.session.live().info.as_ref().unwrap().address, "127.0.0.1:47900");
|
||||
// Stopping the first leaves the second running.
|
||||
let shapes = h.frames(1);
|
||||
h.click(text_center(&shapes, "Stop session"));
|
||||
h.click(text_center(&shapes, "Stop sharing"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Start live session"));
|
||||
assert!(has_text(&shapes, "Start sharing"));
|
||||
assert_eq!((&live(&h)["status"], &live(&h)["sessions"]), (&json!("off"), &json!(1)));
|
||||
run(&mut h, "doc.switch", json!({"index": 1}));
|
||||
assert_eq!((&live(&h)["status"], &live(&h)["session"]), (&json!("live"), &json!(2)));
|
||||
|
||||
@@ -33,7 +33,7 @@ fn escape_closes_the_join_dialog_and_keeps_the_selection() {
|
||||
// Escape with the field not focused (after clicking the dialog's text).
|
||||
crate::menus::invoke(&mut h.app, "collab.join");
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Paste the invite link the host shared with you."));
|
||||
h.click(text_center(&shapes, "Type the join code you were given, or paste an invite link."));
|
||||
h.frames(1);
|
||||
assert!(!link_focused(&h));
|
||||
escape(&mut h);
|
||||
@@ -95,11 +95,10 @@ fn the_browser_joins_but_does_not_host() {
|
||||
let shapes = h.frames(3);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Hosting a session needs the desktop app; here you can join one."));
|
||||
assert!(!has_text(&shapes, "Start live session") && !has_text(&shapes, "Share on the local network"));
|
||||
h.click(text_center(&shapes, "Join a session…"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(h.app.ui.live.join.is_some());
|
||||
assert!(has_text(&shapes, "Sharing a file needs the desktop app; here you can join one."));
|
||||
assert!(!has_text(&shapes, "Start sharing") && !has_text(&shapes, "Local network"));
|
||||
// The join field is right there, with what the browser can reach.
|
||||
assert!(has_text(&shapes, "Join with a code") && has_text(&shapes, "Join"));
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)."
|
||||
|
||||
@@ -62,8 +62,10 @@ fn the_publish_dialog() {
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Or keep the file on a FigCraft server and share links to edit or to view it."));
|
||||
h.click(text_center(&shapes, "Publish to server…"));
|
||||
h.click(text_center(&shapes, "Advanced"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Another server, or one that needs its key. The usual server is set in Preferences."));
|
||||
h.click(text_center(&shapes, "Publish to another server…"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Sharing with a join code in the window: the Share popover not sharing (online or the local
|
||||
//! network, one Start button, the rest under Advanced), sharing online (the code large, copying,
|
||||
//! what it lets people do, who is here, stop), sharing on the local network, a server without
|
||||
//! codes, joining with the one join field (the popover, the home screen, the Join dialog), its
|
||||
//! errors, the server preference, and an idle popover asking for no frames.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Event, Key, Shape};
|
||||
use figcraft_engine::figcraft_collab::code::{self, Local};
|
||||
use figcraft_engine::figcraft_collab::message::ClientMsg;
|
||||
use figcraft_engine::live::{
|
||||
Access, DEFAULT_SERVER, Event as LiveEvent, HostOptions, LiveLink, Peer, Presence, PublishOptions, Published, Role, SessionInfo, Status,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live_tests::{Harness, has_text, text_center, texts};
|
||||
|
||||
/// What the link was asked to do, and the events it hands out next (as the session started last).
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
hosted: Vec<HostOptions>,
|
||||
published: Vec<PublishOptions>,
|
||||
joined: Vec<String>,
|
||||
left: usize,
|
||||
events: Vec<LiveEvent>,
|
||||
session: u64,
|
||||
copied: Vec<String>,
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Script>>;
|
||||
|
||||
struct Link(Shared);
|
||||
|
||||
/// Where the scripted host listens, and its session's secret.
|
||||
const HOST: Local = Local { ip: std::net::Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
|
||||
/// A link without a code in it (an address a code can't carry).
|
||||
const PLAIN: &str = "figcraft://join?host=[fd00::1]:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
fn info(role: Role, link: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
me: 1,
|
||||
name: "Ada".into(),
|
||||
color: "#14AE5C".into(),
|
||||
file_name: "Untitled".into(),
|
||||
pages: vec![],
|
||||
link: link.into(),
|
||||
url: String::new(),
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
let link = if opts.port == Some(6) { PLAIN.to_string() } else { HOST.link() };
|
||||
s.hosted.push(opts);
|
||||
Ok(info(Role::Host, &link))
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push(link.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, session: u64, opts: PublishOptions) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, _: u64) {
|
||||
self.0.lock().unwrap().left += 1;
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, LiveEvent)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let session = s.session;
|
||||
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard, kept where the test can read it.
|
||||
struct Clipboard(Shared);
|
||||
|
||||
impl crate::Services for Clipboard {
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
self.0.lock().unwrap().copied.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// A window on a file, with the scripted link and a server of the test's own in Preferences.
|
||||
fn harness() -> (Harness, Shared) {
|
||||
let (mut h, _) = Harness::new();
|
||||
let script = Shared::default();
|
||||
h.app.session.live.install(Box::new(Link(script.clone())));
|
||||
h.app.services = Box::new(Clipboard(script.clone()));
|
||||
h.app.session.execute("pref.publishServer", &json!({"url": "wss://share.example/"})).unwrap();
|
||||
h.frames(3);
|
||||
(h, script)
|
||||
}
|
||||
|
||||
/// Open the Share popover.
|
||||
fn open(h: &mut Harness) -> Vec<Shape> {
|
||||
let shapes = h.frames(1);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
h.frames(2)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness, shapes: &[Shape], text: &str) -> Vec<Shape> {
|
||||
h.click(text_center(shapes, text));
|
||||
h.frames(2)
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness, key: Key) {
|
||||
for pressed in [true, false] {
|
||||
h.frame(vec![Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type into the field with the keyboard.
|
||||
fn type_text(h: &mut Harness, text: &str) {
|
||||
h.frame(vec![Event::Text(text.into())]);
|
||||
}
|
||||
|
||||
/// The file reached the server and the session is live, with these codes (if the server has any).
|
||||
fn on_the_server(h: &mut Harness, script: &Shared, codes: Option<(&str, &str)>) -> Vec<Shape> {
|
||||
let link = |token: &str| format!("figcraft://join?server=wss://share.example:443/&file=f1&token={token}");
|
||||
let published = Published {
|
||||
server: "wss://share.example:443/".into(),
|
||||
file: "f1".into(),
|
||||
name: "Untitled".into(),
|
||||
edit: link("EditEditEditEditEdit00"),
|
||||
view: link("ViewViewViewViewView00"),
|
||||
owner: Some("OwnerOwnerOwnerOwner00".into()),
|
||||
expires: None,
|
||||
};
|
||||
let mut events = vec![LiveEvent::Published(published.clone())];
|
||||
events.extend(codes.map(|(edit, view)| LiveEvent::Codes { edit: edit.into(), view: view.into() }));
|
||||
events.push(LiveEvent::Welcome { info: info(Role::Guest, &published.edit), peers: vec![] });
|
||||
script.lock().unwrap().events.extend(events);
|
||||
h.frames(3)
|
||||
}
|
||||
|
||||
/// The session's document arrives (the host's welcome): it opens in a tab of its own.
|
||||
fn document_arrives(h: &mut Harness, script: &Shared) {
|
||||
use figcraft_engine::figcraft_collab::message::ServerMsg;
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
let mut server = Server::new(h.app.session.doc().unwrap().clone(), "", Limits::default());
|
||||
let ClientMsg::Hello(me) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
|
||||
let (_, outs) = server.join(me).unwrap();
|
||||
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
script.lock().unwrap().events.push(LiveEvent::Sync(w));
|
||||
h.frames(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() {
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
for t in ["Share this file", "Online", "Local network", "Start sharing", "Join with a code", "Join", "Advanced", "About this server", "Export…"]
|
||||
{
|
||||
assert!(has_text(&shapes, t), "the popover shows {t:?}");
|
||||
}
|
||||
assert!(has_text(&shapes, "People join from anywhere with a code. The file is kept on share.example while you share it."));
|
||||
// No addresses, keys or ports until Advanced is opened.
|
||||
for t in ["Server address", "Port", "This computer only", "Publish to another server…", "WebSocket URL", "Not in a live session"] {
|
||||
assert!(!has_text(&shapes, t), "not in the main flow: {t:?}");
|
||||
}
|
||||
let shapes = click(&mut h, &shapes, "Advanced");
|
||||
assert!(has_text(&shapes, "Publish to another server…") && !has_text(&shapes, "Port"));
|
||||
// The local network: what it means, and its own advanced part.
|
||||
let shapes = click(&mut h, &shapes, "Local network");
|
||||
assert!(has_text(&shapes, "People on your network join with a code. Nothing goes through a server."));
|
||||
assert!(has_text(&shapes, "This computer only") && has_text(&shapes, "Port") && !has_text(&shapes, "Publish to another server…"));
|
||||
assert!(!has_text(&shapes, "About this server"));
|
||||
assert_eq!(crate::live::inspect(&h.app)["local"], true);
|
||||
let shapes = click(&mut h, &shapes, "Advanced");
|
||||
assert!(!has_text(&shapes, "This computer only"), "closed again");
|
||||
// Nothing started, nothing asked of the network.
|
||||
let s = script.lock().unwrap();
|
||||
assert!(s.hosted.is_empty() && s.published.is_empty() && s.joined.is_empty());
|
||||
assert_eq!(h.app.session.live().status, Status::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_online_shows_the_code_copies_it_and_stops() {
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
let shapes = click(&mut h, &shapes, "Start sharing");
|
||||
{
|
||||
let s = script.lock().unwrap();
|
||||
let [p] = s.published.as_slice() else { panic!("published {}", s.published.len()) };
|
||||
assert!(p.server == "wss://share.example/" && p.key.is_none() && p.codes, "the server in Preferences, asked for codes");
|
||||
}
|
||||
assert!(has_text(&shapes, "Starting to share…") && has_text(&shapes, "Stop sharing"));
|
||||
assert!(!has_text(&shapes, "Start sharing"));
|
||||
let shapes = on_the_server(&mut h, &script, Some(("K7M2QX9TD", "4FHJ8R2WN")));
|
||||
for t in
|
||||
["Live session", "Sharing online · nobody else here yet", "K7M-2QX-9TD", "Copy code", "Copy link", "Can edit", "Can view", "Stop sharing"]
|
||||
{
|
||||
assert!(has_text(&shapes, t), "sharing shows {t:?}");
|
||||
}
|
||||
assert!(has_text(&shapes, "Anyone with this code can edit the file.") && has_text(&shapes, "Here: Ada (you)"));
|
||||
// The code is the largest text in the popover.
|
||||
let size = |s: &str| texts(&shapes).into_iter().find(|(t, _, _)| t == s).map(|(_, _, size)| size.y).unwrap();
|
||||
assert!(size("K7M-2QX-9TD") > 1.5 * size("Copy code"));
|
||||
// No owner token dialog in this flow, and no link to read.
|
||||
assert_eq!(crate::live::inspect(&h.app)["ownerDialog"], false);
|
||||
assert!(!has_text(&shapes, "Edit link") && !has_text(&shapes, "Owner token"));
|
||||
assert_eq!(crate::live::inspect(&h.app)["code"], "K7M-2QX-9TD");
|
||||
// Copying.
|
||||
let shapes = click(&mut h, &shapes, "Copy code");
|
||||
let shapes = click(&mut h, &shapes, "Copy link");
|
||||
assert_eq!(
|
||||
script.lock().unwrap().copied,
|
||||
["K7M-2QX-9TD", "figcraft://join?server=wss://share.example:443/&file=f1&token=EditEditEditEditEdit00"]
|
||||
);
|
||||
// Someone joins: they are listed.
|
||||
script.lock().unwrap().events.push(LiveEvent::PeerJoined(Peer { client_id: 2, name: "Hana".into(), color: "#9747FF".into() }));
|
||||
let shapes_now = h.frames(2);
|
||||
assert!(has_text(&shapes_now, "Sharing online · 1 person joined") && has_text(&shapes_now, "Here: Ada (you), Hana"));
|
||||
// What the code lets people do: the view code and link take the edit ones' place.
|
||||
let shapes = click(&mut h, &shapes, "Can view");
|
||||
assert!(has_text(&shapes, "4FH-J8R-2WN") && !has_text(&shapes, "K7M-2QX-9TD"));
|
||||
assert!(has_text(&shapes, "Anyone with this code can look at the file, not change it."));
|
||||
let shapes = click(&mut h, &shapes, "Copy link");
|
||||
assert!(script.lock().unwrap().copied.last().unwrap().ends_with("token=ViewViewViewViewView00"));
|
||||
let shapes = click(&mut h, &shapes, "Can edit");
|
||||
assert!(has_text(&shapes, "K7M-2QX-9TD"));
|
||||
// Stop: back to the start, the session left.
|
||||
let shapes = click(&mut h, &shapes, "Stop sharing");
|
||||
assert!(has_text(&shapes, "Start sharing") && has_text(&shapes, "Share this file") && !has_text(&shapes, "K7M-2QX-9TD"));
|
||||
assert_eq!((h.app.session.live().status.clone(), script.lock().unwrap().left), (Status::Off, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_on_the_local_network_shows_its_longer_code() {
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
let shapes = click(&mut h, &shapes, "Local network");
|
||||
let shapes = click(&mut h, &shapes, "Start sharing");
|
||||
let shown = code::grouped(&HOST.code());
|
||||
for t in ["Sharing on your network · nobody else here yet", shown.as_str(), "Copy code", "Copy link", "Stop sharing"] {
|
||||
assert!(has_text(&shapes, t), "sharing shows {t:?}");
|
||||
}
|
||||
assert!(has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open."));
|
||||
assert!(!has_text(&shapes, "Can view"), "everyone edits on the local network");
|
||||
{
|
||||
let s = script.lock().unwrap();
|
||||
assert!(s.hosted[0].short && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none());
|
||||
assert!(s.published.is_empty(), "nothing goes to a server");
|
||||
}
|
||||
let shapes = click(&mut h, &shapes, "Copy code");
|
||||
let shapes = click(&mut h, &shapes, "Copy link");
|
||||
assert_eq!(script.lock().unwrap().copied, [shown, HOST.link()]);
|
||||
// Advanced: this computer only, on a port of your choosing.
|
||||
let shapes = click(&mut h, &shapes, "Stop sharing");
|
||||
let shapes = click(&mut h, &shapes, "Advanced");
|
||||
let shapes = click(&mut h, &shapes, "This computer only");
|
||||
h.app.ui.live.port = "6".into();
|
||||
click(&mut h, &shapes, "Start sharing");
|
||||
let s = script.lock().unwrap();
|
||||
assert!(s.hosted[1].bind == figcraft_engine::live::Bind::Local && s.hosted[1].port == Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_code_the_link_is_shared_instead() {
|
||||
// A server from before join codes.
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
click(&mut h, &shapes, "Start sharing");
|
||||
let shapes = on_the_server(&mut h, &script, None);
|
||||
assert!(has_text(&shapes, "Sharing online · nobody else here yet") && has_text(&shapes, "Invite link") && has_text(&shapes, "Copy"));
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead."
|
||||
));
|
||||
assert!(!has_text(&shapes, "Copy code"));
|
||||
let shapes = click(&mut h, &shapes, "Copy");
|
||||
assert!(script.lock().unwrap().copied[0].ends_with("token=EditEditEditEditEdit00"));
|
||||
// A host whose address a code can't carry.
|
||||
let shapes = click(&mut h, &shapes, "Stop sharing");
|
||||
let shapes = click(&mut h, &shapes, "Local network");
|
||||
h.app.ui.live.port = "6".into();
|
||||
let shapes = click(&mut h, &shapes, "Start sharing");
|
||||
assert!(has_text(&shapes, "Invite link") && !has_text(&shapes, "Copy code"));
|
||||
click(&mut h, &shapes, "Copy");
|
||||
assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_join_field_takes_a_code_or_a_link_and_enter_joins() {
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
// Click into the field (its hint), type a code the way people type it, press Enter.
|
||||
click(&mut h, &shapes, "Code or invite link");
|
||||
assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_code"))));
|
||||
type_text(&mut h, " k7m 2qx-9td ");
|
||||
assert_eq!(crate::live::inspect(&h.app)["joinField"], " k7m 2qx-9td ");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=K7M2QX9TD"]);
|
||||
assert!(h.app.ui.live.code.is_empty(), "the field is cleared once the join started");
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Joining…") && has_text(&shapes, "Leave session"));
|
||||
// The server doesn't know the code: the popover says so and offers the field again.
|
||||
script.lock().unwrap().events.push(LiveEvent::Status(Status::Failed { reason: "No session with that code".into() }));
|
||||
let shapes = h.frames(3);
|
||||
assert!(has_text(&shapes, "No session with that code") && has_text(&shapes, "Join with a code") && has_text(&shapes, "Start sharing"));
|
||||
// Something that can't be a code: it says why and stays to be fixed (the Join button).
|
||||
click(&mut h, &shapes, "Code or invite link");
|
||||
type_text(&mut h, "K7M-2QX");
|
||||
let shapes = h.frames(1);
|
||||
click(&mut h, &shapes, "Join");
|
||||
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("9 characters")), "{:?}", h.app.ui.last_error);
|
||||
assert_eq!(h.app.ui.live.code, "K7M-2QX");
|
||||
assert_eq!(script.lock().unwrap().joined.len(), 1);
|
||||
// A local network code and a pasted link go through the same field.
|
||||
for (typed, want) in [(code::grouped(&HOST.code()), HOST.link()), (PLAIN.to_string(), PLAIN.to_string())] {
|
||||
h.app.ui.live.code = typed;
|
||||
let shapes = h.frames(1);
|
||||
click(&mut h, &shapes, "Join");
|
||||
assert_eq!(script.lock().unwrap().joined.last(), Some(&want));
|
||||
h.app.session.execute("collab.leave", &json!({})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_home_screen_joins_with_a_code_and_opens_the_file() {
|
||||
let (mut h, script) = harness();
|
||||
h.app.ui.home = true;
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Join with a code") && has_text(&shapes, "Code or invite link") && has_text(&shapes, "Join"));
|
||||
click(&mut h, &shapes, "Code or invite link");
|
||||
type_text(&mut h, "K7M-2QX-9TD");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=K7M2QX9TD"]);
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Joining…") && h.app.ui.home);
|
||||
// It failed: the reason shows under the field, and the home screen stays.
|
||||
script.lock().unwrap().events.push(LiveEvent::Status(Status::Failed { reason: "That code has expired or its sharing was stopped".into() }));
|
||||
let shapes = h.frames(3);
|
||||
assert!(has_text(&shapes, "That code has expired or its sharing was stopped") && h.app.ui.home);
|
||||
// Again, and it works: the shared file's tab comes to the front.
|
||||
h.app.ui.live.code = "K7M-2QX-9TD".into();
|
||||
let shapes = h.frames(1);
|
||||
click(&mut h, &shapes, "Join");
|
||||
assert_eq!(script.lock().unwrap().joined.len(), 2);
|
||||
script.lock().unwrap().events.push(LiveEvent::Welcome { info: info(Role::Guest, PLAIN), peers: vec![] });
|
||||
h.frames(2);
|
||||
document_arrives(&mut h, &script);
|
||||
assert!(!h.app.ui.home, "the joined file shows");
|
||||
assert_eq!(h.app.session.docs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_join_dialog_is_the_same_field() {
|
||||
let (mut h, script) = harness();
|
||||
crate::menus::invoke(&mut h.app, "collab.join");
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Type the join code you were given, or paste an invite link.") && has_text(&shapes, "Code or invite link"));
|
||||
assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_link"))), "the field has the keyboard");
|
||||
type_text(&mut h, "4fh j8r 2wn");
|
||||
key(&mut h, Key::Enter);
|
||||
h.frames(1);
|
||||
assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=4FHJ8R2WN"]);
|
||||
assert!(h.app.ui.live.join.is_none(), "it closes once the join started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_preference_resets_and_turns_online_sharing_off() {
|
||||
let (mut h, script) = harness();
|
||||
h.app.ui.preferences = true;
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Server for online sharing") && has_text(&shapes, "Reset"));
|
||||
click(&mut h, &shapes, "Reset");
|
||||
assert_eq!(h.app.session.prefs.publish_server, DEFAULT_SERVER);
|
||||
// Off: Preferences say what that means, and the popover offers the local network only.
|
||||
h.app.session.execute("pref.publishServer", &json!({"url": ""})).unwrap();
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Online sharing is off: files are shared on the local network only."));
|
||||
h.app.ui.preferences = false;
|
||||
h.frames(2);
|
||||
let shapes = open(&mut h);
|
||||
assert!(has_text(&shapes, "Sharing on the local network (online sharing is off: no server is set in Preferences)."));
|
||||
assert!(!has_text(&shapes, "Online") && has_text(&shapes, "Start sharing"));
|
||||
click(&mut h, &shapes, "Start sharing");
|
||||
let s = script.lock().unwrap();
|
||||
assert!(s.published.is_empty() && s.hosted.len() == 1, "shared on the local network");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_popover_asks_for_no_frames() {
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
assert!(h.goes_quiet(), "not sharing");
|
||||
click(&mut h, &shapes, "Start sharing");
|
||||
on_the_server(&mut h, &script, Some(("K7M2QX9TD", "4FHJ8R2WN")));
|
||||
assert!(h.goes_quiet(), "sharing, with the code showing");
|
||||
let (shapes, delay) = h.frame(vec![]);
|
||||
assert!(has_text(&shapes, "K7M-2QX-9TD") && delay >= Duration::from_secs(1));
|
||||
}
|
||||
@@ -332,18 +332,20 @@ fn share_popover_and_join_dialog() {
|
||||
let share = text_center(&shapes, "Share");
|
||||
h.click(share);
|
||||
let shapes = h.frames(2);
|
||||
for t in ["Live session", "Start live session", "Share on the local network", "Export…"] {
|
||||
for t in ["Share this file", "Online", "Local network", "Start sharing", "Join with a code", "Export…"] {
|
||||
assert!(has_text(&shapes, t), "the popover shows {t:?}");
|
||||
}
|
||||
// Starting fails with this link: the error shows, nothing else changes.
|
||||
h.click(text_center(&shapes, "Start live session"));
|
||||
h.click(text_center(&shapes, "Local network"));
|
||||
let shapes = h.frames(2);
|
||||
h.click(text_center(&shapes, "Start sharing"));
|
||||
assert_eq!(h.app.session.live().status, Status::Off);
|
||||
assert!(h.app.ui.last_error.as_deref().is_some_and(|e| e.contains("not in this test")));
|
||||
// File ▸ Join live session opens the dialog.
|
||||
h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
|
||||
crate::menus::invoke(&mut h.app, "collab.join");
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Join live session") && has_text(&shapes, "Paste the invite link the host shared with you."));
|
||||
assert!(has_text(&shapes, "Join live session") && has_text(&shapes, "Type the join code you were given, or paste an invite link."));
|
||||
assert_eq!(h.app.inspect()["live"]["joinDialog"], true);
|
||||
h.click(text_center(&shapes, "Cancel"));
|
||||
h.frames(1);
|
||||
|
||||
@@ -533,27 +533,55 @@ pub fn elide_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String {
|
||||
|
||||
/// A row of icon buttons; returns the index clicked.
|
||||
pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option<usize>, cell: f32) -> Option<usize> {
|
||||
let names: Vec<&str> = items.iter().map(|(_, tip)| *tip).collect();
|
||||
segments(ui, &names, active, cell, true, |ui, r, i, on, t| {
|
||||
if let Some((icon, _)) = items.get(i) {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), *icon, if on { t.icon } else { t.icon_secondary });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A choice between a few labelled options across the available width (the active one raised);
|
||||
/// returns the index clicked. A label too long for its cell ends with an ellipsis.
|
||||
pub fn segmented_text(ui: &mut Ui, labels: &[&str], active: usize) -> Option<usize> {
|
||||
let cell = (ui.available_width() / labels.len().max(1) as f32).floor();
|
||||
segments(ui, labels, Some(active), cell, false, |ui, r, i, on, t| {
|
||||
let label = labels.get(i).copied().unwrap_or_default();
|
||||
let font = if on { semibold(11.0) } else { regular(11.0) };
|
||||
let shown = elide_font(ui, label, r.width() - 12.0, font.clone());
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, font, if on { t.text } else { t.text_secondary });
|
||||
})
|
||||
}
|
||||
|
||||
/// The cells of a segmented control, each `cell` wide and named by `names` (shown on hover when
|
||||
/// `tips`), drawn by `paint(ui, cell, index, active, tokens)`; returns the index clicked.
|
||||
fn segments(
|
||||
ui: &mut Ui,
|
||||
names: &[&str],
|
||||
active: Option<usize>,
|
||||
cell: f32,
|
||||
tips: bool,
|
||||
paint: impl Fn(&Ui, Rect, usize, bool, &Tokens),
|
||||
) -> Option<usize> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = cell * items.len() as f32;
|
||||
let w = cell * names.len() as f32;
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
|
||||
let mut out = None;
|
||||
for (i, (icon, tip)) in items.iter().enumerate() {
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(rect.min.x + i as f32 * cell, rect.min.y), vec2(cell, FIELD_H));
|
||||
let resp = ui.interact(r, ui.id().with(("seg", i, *tip)), Sense::click()).on_hover_text(*tip);
|
||||
a11y::toggle(&resp, tip, active == Some(i));
|
||||
let mut resp = ui.interact(r, ui.id().with(("seg", i, *name)), Sense::click());
|
||||
if tips {
|
||||
resp = resp.on_hover_text(*name);
|
||||
}
|
||||
a11y::toggle(&resp, name, active == Some(i));
|
||||
if active == Some(i) {
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.panel);
|
||||
ui.painter().rect_stroke(r.shrink(2.0), CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.hover);
|
||||
}
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(r.center(), vec2(14.0, 14.0)),
|
||||
*icon,
|
||||
if active == Some(i) { t.icon } else { t.icon_secondary },
|
||||
);
|
||||
paint(ui, r, i, active == Some(i), &t);
|
||||
if resp.clicked() {
|
||||
out = Some(i);
|
||||
}
|
||||
@@ -561,6 +589,22 @@ pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option<usize>, cel
|
||||
out
|
||||
}
|
||||
|
||||
/// A row that shows or hides what is under it: a chevron and `label`; returns whether it was
|
||||
/// clicked.
|
||||
pub fn disclosure(ui: &mut Ui, label: &str, open: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.text_secondary);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 22.0, FIELD_H), Sense::click());
|
||||
a11y::disclosure(&resp, label, open);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let chevron = Rect::from_center_size(pos2(rect.min.x + 8.0, rect.center().y), vec2(10.0, 10.0));
|
||||
icons::paint(ui.painter(), chevron, if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary);
|
||||
ui.painter().galley(pos2(rect.min.x + 18.0, rect.center().y - galley.size().y / 2.0), galley, t.text_secondary);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// A section header ("Fill", "Stroke"…) with optional right-side icon buttons; returns which
|
||||
/// button was clicked. `dim` greys the title (empty sections).
|
||||
pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> Option<usize> {
|
||||
|
||||
+82
-12
@@ -13,7 +13,9 @@ modes and comments created in a session take ids from their creator's id space,
|
||||
carets, cursor chat and spotlights show live (see Presence). **Step 5 is implemented**: the
|
||||
standalone `figcraft-server` (`apps/figcraft-server`, running it: [`server.md`](server.md)) keeps
|
||||
many files with persistence and compaction, and share links that let people edit or only view;
|
||||
the app publishes to it, and a file's team library lives there for other files to use. Sessions
|
||||
the app publishes to it, and a file's team library lives there for other files to use. Files are
|
||||
shared with **join codes** (see *Sharing with a join code*): online through a server that names
|
||||
its links with short codes, or on the local network with a code that carries the host's address. Sessions
|
||||
are per document: an app hosts, joins and publishes several files at once (see Hosting and
|
||||
joining). The rest is the plan the next implementation PRs follow and update;
|
||||
sections say what is built.
|
||||
@@ -182,6 +184,13 @@ welcome, and a client sends `Report` only to a server that sent it one. New vari
|
||||
at the end of an enum, and existing messages keep their fields, so nothing an older peer reads
|
||||
changes.
|
||||
|
||||
**Join codes** came later within version 2 the same way: a connection that opens with `Resolve`
|
||||
asks a server which file a code opens, and `Admin code` asks it for a link's code. Both are new
|
||||
variants at the end of their enums, so everything an older peer reads is unchanged. A server from
|
||||
before join codes can't read either message and refuses the connection ("that message couldn't be
|
||||
read"): the app then says the server doesn't know join codes (joining) or shows the invite links in
|
||||
place of a code (sharing). An older app never sends them and joins by link as before.
|
||||
|
||||
**Encoding (decided):** a binary message is a 1-byte wire version followed by **postcard** (serde),
|
||||
which is compact and needs no schema files. Property values inside are compact JSON text: the
|
||||
document types use tagged and flattened serde forms that only self-describing formats can read
|
||||
@@ -211,6 +220,8 @@ Client → server:
|
||||
| `Admin {rename {file, name} \| listing \| reports \| block {ip} \| unblock {ip} \| blocked}` | Later managing requests: rename a file on the server, the files with what the operator moderates by, the reports, blocking addresses (an IPv6 address: its /64). Owner tokens may only rename their file. |
|
||||
| `Report {reason}` | On a joined connection, only to a server that sent `Hosting`: report the file to the operator (at most 500 characters kept; ten an hour per address). |
|
||||
| `Plugins {list {query} \| fetch {id, version?} \| publish {bundle} \| unpublish {id, version?}}` | A request on a plugin directory connection; each gets one `Plugins` reply, in order. `bundle` is a plugin bundle's bytes (`figcraft_plugin::bundle`). |
|
||||
| `Resolve {protocol, client_version, code}` | Instead of `Hello`: ask which file a join code opens (the code's nine symbols). Answered with `Resolved`, then the server closes the connection; a code it doesn't know, too many wrong ones from the address, or a session hosted by an app is `Refused` with the reason. |
|
||||
| `Admin {code {file, token}}` | A later managing request (with the server key, or the file's owner token on an open hosting connection): a new join code for the link of `file` whose token this is. A link has one code; asking again replaces it. |
|
||||
|
||||
Server → client:
|
||||
|
||||
@@ -229,6 +240,8 @@ Server → client:
|
||||
| `Admin {hosted {file, edit, view, owner, expires?} \| listing [row] \| reports [report] \| blocked [ip]}` | A file published without the key with its links and owner token (shown once), and the answers to the later managing requests. |
|
||||
| `Reported {error?}` | The answer to `Report`: kept, or why not. |
|
||||
| `Plugins {ready {publisher} \| list [entry] \| bundle {entry, bundle} \| published entry \| removed [version] \| failed {reason}}` | A plugin directory connection's answer: opened (with the key or not), the latest version of each plugin with the versions kept, a bundle and its entry (its SHA-256 among them), kept, unpublished, or why not. |
|
||||
| `Resolved {file, token}` | The answer to `Resolve`: the file the code opens and its link's token, which the client then joins with in an ordinary `Hello` on a new connection (so reconnects, access and revocation are the link's). |
|
||||
| `Admin {code {code}}` | The answer to `Admin code`: the link's new join code, shown this once (the server keeps only what it needs to recognise it). |
|
||||
|
||||
`Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a
|
||||
node deletes its subtree; ids are never allocated twice), plus the document-level `SetDoc`, `Put`
|
||||
@@ -413,8 +426,9 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
|
||||
## Hosting and joining
|
||||
|
||||
- **Host from the app** (first step, no public server): "Share ▸ Start live session" starts the
|
||||
embedded server on the local machine for the open file and shows an invite link
|
||||
- **Host from the app** (first step, no public server): "Share ▸ Local network ▸ Start sharing"
|
||||
(or `collab.host`) starts the embedded server on the local machine for the open file and shows
|
||||
a join code and an invite link
|
||||
(`figcraft://join?host=192.168.1.20:47900&file=<id>&token=<secret>`; also a plain
|
||||
`ws://`/`wss://` URL). Others on the same network (or through a forwarded port / VPN such as a
|
||||
mesh VPN) join with "File ▸ Join live session" or by opening the link. When the host leaves, the
|
||||
@@ -428,11 +442,15 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
|
||||
*Built* (hosting from the app and joining):
|
||||
|
||||
- "Share" in the right panel opens a popover: your name, **Share on the local network** (off:
|
||||
127.0.0.1 only), **Start live session**; while hosting, the invite link and the `ws://` URL with
|
||||
Copy buttons, how many joined, and **Stop session**; while joined, **Leave session**. "File ▸
|
||||
Join live session…" (and "Join a session…" in the popover) asks for the link. Commands:
|
||||
`collab.host {port?, bind?: "local" | "lan" | <ip>}`, `collab.stop`, `collab.join {link}`,
|
||||
- "Share" in the right panel opens a popover: your name, a choice between **Online** and **Local
|
||||
network**, and **Start sharing**; while sharing, the join code (large), **Copy code** and **Copy
|
||||
link**, what the code lets people do (online: can edit or can view), who is here, and **Stop
|
||||
sharing**; while joined, **Leave session**. **Join with a code** is one field in the popover, on
|
||||
the home screen and in "File ▸ Join live session…": it takes a code of either kind or an invite
|
||||
link. Another server, a server key, the port and "this computer only" are under **Advanced**;
|
||||
the server for online sharing is in Preferences (see *Sharing with a join code*). Commands:
|
||||
`collab.share {via?: "online" | "local", access?, …}`,
|
||||
`collab.host {port?, bind?: "local" | "lan" | <ip>}`, `collab.stop`, `collab.join {code | link}`,
|
||||
`collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's
|
||||
pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`.
|
||||
- **Sessions are per document.** Every open file has its own session or none: hosting, joined,
|
||||
@@ -468,6 +486,52 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
the messages it read and sends them in its next `Hello` (see Offline and reconnect).
|
||||
- The browser build joins (see *Built (the browser client)*); hosting needs the desktop app.
|
||||
|
||||
*Built* (sharing with a join code; `figcraft_collab::code`, `collab.share`, `collab.join {code}`):
|
||||
|
||||
- **Why codes.** An invite link is long and carries a 128-bit secret; a code is what one person
|
||||
reads to another. Both kinds use Crockford's base32 alphabet (digits and capitals without `I`,
|
||||
`L`, `O`, `U`); reading one ignores case, spaces and dashes and takes `O` as `0` and `I` or `L`
|
||||
as `1`. The join field tells the kinds apart by length (anything with `://` is a link).
|
||||
- **Online** (`collab.share {via: "online"}`, the default): the open file is published to the
|
||||
server in Preferences (`pref.publishServer`; built in: `wss://collab.craftapps.dev/`, written
|
||||
once, as `figcraft_engine::live::DEFAULT_SERVER`; empty turns online sharing off) exactly as
|
||||
`collab.publish` does on a server open to the public, owner token included; the app then asks
|
||||
for a code for the edit link and one for the view link (`Admin code`, as the file's owner) and
|
||||
shows the edit code, `K7M-2QX-9TD`: **nine symbols, 45 random bits**, a different code per link.
|
||||
A joiner's app sends `Resolve {code}` to its own server in Preferences, gets the file and the
|
||||
link's token, and joins with them as with the link. **Stop sharing** (`collab.stop`) leaves and
|
||||
has the server remove the file (`Admin remove` with the owner token): codes and links stop
|
||||
working and everyone keeps the copy they have. `collab.leave` keeps the file on the server.
|
||||
- **What the server keeps of a code.** Never the code. It is stretched (SHA-256 of the code under
|
||||
a fixed label, then 10,000 more rounds); a hash of the result finds the link, and another is the
|
||||
pad the link's token is wrapped with (XOR), so only the code gives the token back. Lookups
|
||||
compare every link's hash in constant time. A code goes with its link: revoking the link,
|
||||
removing the file or the file expiring ends it, and a newer code for the link replaces it (such
|
||||
a code is answered "That code has expired or its sharing was stopped" until the server restarts,
|
||||
then "No session with that code").
|
||||
- **Guessing online.** 45 bits are far fewer than a link's 128, so the server limits tries, on
|
||||
every server (private ones too): an address may try 10 codes a minute; 10 wrong ones in a row
|
||||
(within 15 minutes of each other) lock it out of codes for 15 minutes, during which nothing is
|
||||
looked up for it; and everyone together may get 1,000 wrong an hour, after which codes are
|
||||
paused for all until the budget refills (links keep joining). IPv6 addresses count by /64, and
|
||||
their /56 and /48 with 2 and 4 times the limits. With 20,000 live codes a guess is right with
|
||||
probability about 6 in 10 billion, so the hourly budget gives a chance of roughly one hit in two
|
||||
hundred years; an attacker can make codes unavailable for a while, not find one. A copy of the
|
||||
data directory lets someone try codes offline at 10,000 hashes a guess: slow, not impossible
|
||||
(the files themselves are in that directory, unencrypted).
|
||||
- **Local network** (`collab.share {via: "local"}`): the app hosts the session itself (as
|
||||
`collab.host` on the local network's interface) and there is no server to ask, so the code
|
||||
carries what a joiner needs: **20 symbols** (shown in fives), 100 bits: the host's IPv4 address
|
||||
(32), its port (16), the session's secret (44) and 8 check bits that catch a mistyped code
|
||||
before anything connects. The session's token is that secret (`local-<11 hex digits>`, file id
|
||||
`local`), so a local share is protected by 44 bits, not 128: a code is guessed over the network
|
||||
at the rate the host accepts connections, which makes 44 bits years of trying, but treat it as
|
||||
a secret for the local network, not for the internet. The invite link names the same session.
|
||||
Whoever sees the code learns the host's address. Everyone who joins edits; a host without an
|
||||
IPv4 address gets no code and shares its link.
|
||||
- **In the browser** a code joins like a link (the page asks the server over its own WebSocket,
|
||||
then opens the session's); sharing needs the desktop app.
|
||||
|
||||
*Built* (document sync between apps):
|
||||
|
||||
- The host's session runs the core's `Server`, seeded with the document it shared; the host's
|
||||
@@ -543,7 +607,7 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
128 bits in the data directory. The server's command line (`figcraft-server files|links|key`)
|
||||
uses the same requests when a server is running on the directory, else it changes the directory
|
||||
itself (one process holds it at a time: an OS file lock).
|
||||
- **Publishing from the app.** `collab.publish {server, name?}` (Share ▸ Publish to server…)
|
||||
- **Publishing from the app.** `collab.publish {server, name?}` (File ▸ Publish to server…)
|
||||
uploads the open document over a managing connection, then joins the new file with its edit
|
||||
link on the same connection thread; the tab becomes the session's synced document (read-only
|
||||
until the welcome arrives, its undo history per person after) and `collab.peers` reports
|
||||
@@ -708,8 +772,8 @@ saved state.
|
||||
|
||||
## Agents, MCP and the CLI
|
||||
|
||||
Everything stays a command: `collab.host`, `collab.join`, `collab.publish`, `collab.leave`,
|
||||
`collab.peers`, `collab.follow` are ordinary commands, so the CLI, the control channel and MCP can
|
||||
Everything stays a command: `collab.share`, `collab.host`, `collab.join`, `collab.publish`,
|
||||
`collab.leave`, `collab.peers`, `collab.follow` are ordinary commands, so the CLI, the control channel and MCP can
|
||||
host, publish and join sessions; an agent can be a participant with its own cursor.
|
||||
|
||||
*Built*: the desktop app (control channel) and the MCP server's headless session (`figcraft-cli
|
||||
@@ -781,6 +845,11 @@ chooses to share on the network; the default is the invite link with a random to
|
||||
(listing files, other files' links, reports, blocks, the plugin directory's publishing) needs the
|
||||
server key. Files are not end-to-end encrypted: the server reads what it keeps, and so can its
|
||||
operator.
|
||||
- **Join codes** are weaker secrets than links by design (45 bits online, 44 on the local
|
||||
network): online they stand on the server's limits on wrong codes, locally on how fast a host
|
||||
takes connections (see *Sharing with a join code*). A code is never stored, logged or sent back
|
||||
after it is handed out; it travels only in the first message of a connection, never in the
|
||||
WebSocket request. Anything that needs a stronger secret shares the link.
|
||||
- **TLS.** None in the server: a reverse proxy terminates it (Caddy and nginx examples in
|
||||
`packaging/server/`, deployment in [`server.md`](server.md#deploy-on-a-linux-server)); clients
|
||||
speak `wss://` to the proxy. Tokens and keys are never in the
|
||||
@@ -811,5 +880,6 @@ chooses to share on the network; the default is the invite link with a random to
|
||||
5. **Standalone `figcraft-server`** with persistence, compaction, share links and permissions.
|
||||
**Done** (`apps/figcraft-server` on the shared hub; edit and view links, the server key,
|
||||
publishing from the app; see [`server.md`](server.md)), followed by open hosting for a public
|
||||
server (owner tokens, limits per address, idle expiry, reports and blocks).
|
||||
server (owner tokens, limits per address, idle expiry, reports and blocks) and by sharing with
|
||||
join codes (online through the server, or carrying the host's address on the local network).
|
||||
6. **Accounts**: people signing in, per-person permissions, teams and projects.
|
||||
|
||||
@@ -12,10 +12,10 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Feature | Figma | FigCraft | Hours |
|
||||
|---|---|---|---|
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (Share ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 |
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`, no app UI yet), anyone in a file can report it, and the operator blocks addresses. No accounts, teams or projects | 8–17 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`, no app UI yet), anyone in a file can report it, and the operator blocks addresses. No accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
|
||||
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the file leaves the server); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Live sessions are per document (a fix from real use: after hosting or joining in one tab, a new or other file showed that session, and starting one there silently replaced it). Every open file has its own session or none; several run at once and keep syncing behind the tab in front; the Share popover, the people, the cursors, cursor chat, spotlight, follow and the read-only reason are the file in front's (each window its own); the `collab.*` commands act on the active file's session and `collab.peers` lists all of them (`sessions`); joining opens the session's file in its own tab without touching other sessions; closing a file ends its session only. One link carries the sessions, each under its own id (desktop, browser and tests). Percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged |
|
||||
|
||||
@@ -103,10 +103,13 @@ session of the window's active file as the window shows it (sessions are per fil
|
||||
shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one),
|
||||
`sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the
|
||||
peers whose cursors are drawn on the canvas (`cursorsShown`), whether the Join and Publish to
|
||||
server dialogs are open (`joinDialog`, `publishDialog`; Escape closes them) and why the active
|
||||
server dialogs are open (`joinDialog`, `publishDialog`; Escape closes them), the join code the
|
||||
Share popover shows (`code`, with `share: {via, access}`; null when the file isn't shared with
|
||||
one), what is typed in the popover's and the home screen's join field (`joinField`), whether the
|
||||
popover has Local network chosen and Advanced open (`local`, `advanced`), and why the active
|
||||
document can't be edited now (`readOnly`: the "View only" or "Publishing" reason, else null); the
|
||||
session itself is `engine.execute {command: "collab.peers"}` (hosting, joining and publishing are
|
||||
`collab.host`, `collab.join` and `collab.publish`, opening invite links `collab.registerLinks`,
|
||||
session itself is `engine.execute {command: "collab.peers"}` (sharing with a join code, hosting,
|
||||
joining and publishing are `collab.share`, `collab.host`, `collab.join` and `collab.publish`, opening invite links `collab.registerLinks`,
|
||||
see [mcp.md](mcp.md#live-sessions)). The app also joins the invite link it
|
||||
was started with (`figcraft "figcraft://join?…"`).
|
||||
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
@@ -84,7 +84,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
edits rolled back with a reason, reconnects that resume and send what was made offline, and
|
||||
styles, variables and comments that several people create at once all kept; sessions are per
|
||||
document (each open file has its own or none, several at once, closing a file ends only its
|
||||
own); a standalone
|
||||
own); files are shared with a short join code (online through the server in Preferences, or on
|
||||
the local network with a code that carries the host's address) and joined by typing it; a standalone
|
||||
`figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing
|
||||
confirmed), takes files published from the app and hands out share links that let people edit
|
||||
or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse
|
||||
@@ -385,6 +386,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged |
|
||||
| 2026-10-12 | minor | Gap 3: first measured fidelity data. Text box sizes and auto layout results of our example documents were compared with the same documents re-saved by other software; three text rules (whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only) were found and applied, and all 230 self-sizing text boxes in 7 documents now match to the pixel (one font family). Real files are still unchecked: hours and the ranking unchanged |
|
||||
| 2026-10-12 | minor | Gap 2: live sessions are per document (each open file has its own session or none, several run at once, the window and the `collab.*` commands follow the file in front, closing a file ends its session only) added to the landed list; a fix, so the estimate and ranking are unchanged |
|
||||
| 2026-10-12 | minor | Gap 1: the experimental `.fig` export now opens elsewhere: written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Hours and the ranking unchanged |
|
||||
|
||||
+23
-3
@@ -894,6 +894,20 @@ and `collab.peers` are about the **active document's** session (`doc.switch {ind
|
||||
one that is), and `sessions` in `collab.peers` lists them all. Closing a document ends its session
|
||||
only: a host stops hosting it, a guest leaves it.
|
||||
|
||||
- `collab.share {via?, access?, server?, name?, port?, bind?}` shares the active document with a
|
||||
**join code**, the short way to do what `collab.publish` and `collab.host` do. `via: "online"`
|
||||
(the default) publishes it to the server in Preferences (`pref.publishServer {url?}`: the
|
||||
FigCraft server unless changed; `{url: ""}` turns online sharing off, `{}` goes back to the
|
||||
built-in one; `server` names another for this call, with `?key=…` when it needs its key) and
|
||||
returns `status: "connecting"`; once `collab.peers` says `"live"`, `code` is the code to give
|
||||
others (`K7M-2QX-9TD`) and `share` is `{via, access, editCode, viewCode}`. `access: "view"` (or
|
||||
calling again with it while shared) makes `code` the view-only one. A server older than join
|
||||
codes leaves `code` null: share `published.edit` / `published.view` instead. `via: "local"`
|
||||
hosts the document for people on the same network and returns at once with a longer `code`
|
||||
(20 symbols, carrying this machine's IPv4 address, the port and the session's secret; `bind:
|
||||
"local"` keeps it to this machine); everyone who joins edits. **Calling it publishes the
|
||||
document to a server other people run: do it only when asked to share.** `collab.stop {}` ends
|
||||
it (a file shared online is removed from the server, so its codes and links stop working).
|
||||
- `collab.host {port?, bind?}` starts hosting the active document (a session that document was in
|
||||
ends; other documents' sessions go on): `bind` is `"local"` (this
|
||||
machine, the default), `"lan"` (the local network) or an interface address; the port is 47900,
|
||||
@@ -902,13 +916,17 @@ only: a host stops hosting it, a guest leaves it.
|
||||
`collab.peers` does, with `link` (`figcraft://join?host=…&file=…&token=…`) and `url`
|
||||
(`ws://…/?file=…&token=…`): both carry the session's secret, so share them only with people you
|
||||
invite. `collab.stop {}` ends the active document's session.
|
||||
- `collab.join {link}` (any form: an app's invite, a `figcraft-server` share link, or the
|
||||
`ws(s)://…?file=…&token=…` URL) returns at once with the new session (`session`, `status:
|
||||
- `collab.join {code}` or `collab.join {link}` (a join code of either kind: spaces, dashes and
|
||||
case don't matter, `O` reads as `0` and `I` or `L` as `1`; or a link in any form: an app's
|
||||
invite, a `figcraft-server` share link, or the `ws(s)://…?file=…&token=…` URL; either parameter
|
||||
takes either) returns at once with the new session (`session`, `status:
|
||||
"connecting"`) and touches no other document's session. Its file opens as a new document, which
|
||||
becomes the active one: poll
|
||||
`collab.peers` until `status` is `"live"`. Until then the join shows on the document it was
|
||||
started from when that one isn't in a session itself, and in `sessions` either way, where it
|
||||
becomes `"failed"` with a `reason` when it can't be joined (a wrong token, a full
|
||||
becomes `"failed"` with a `reason` when it can't be joined ("No session with that code", "That
|
||||
code has expired or its sharing was stopped", "Couldn't reach the server (…)", too many wrong
|
||||
codes from your address, a wrong token, a full
|
||||
session, a revoked link, an unreachable host, a `wss://` server whose certificate isn't trusted:
|
||||
see `FIGCRAFT_CA_FILE` in [`server.md`](server.md#behind-a-reverse-proxy-tls)). `collab.leave {}`
|
||||
leaves the active document's session (or ends a join started from it that is still on its way
|
||||
@@ -945,6 +963,8 @@ only: a host stops hosting it, a guest leaves it.
|
||||
attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network,
|
||||
access (edit|view), readOnly (the active document can't be edited now: view only, or being
|
||||
published), published (a file you published: {server, file, name, edit, view, owner, expires}),
|
||||
code (the join code to give others when the document was shared with `collab.share`, else
|
||||
null), share ({via: online|local, access, editCode, viewCode}, or null),
|
||||
hosting (what the server of the file you're in says: {open, operator, terms, idleDays, maxFileMb,
|
||||
expires}, or null),
|
||||
follow, signals (the host passes cursor chat and spotlights on), chat (your cursor chat),
|
||||
|
||||
+56
-10
@@ -52,7 +52,7 @@ figcraft-server --data /srv/figcraft key
|
||||
```
|
||||
|
||||
prints the address to publish to, `ws://<host>:47900/?key=<server key>`. In the desktop app,
|
||||
**Share ▸ Publish to server…** (or **File ▸ Publish to server…**) asks for that address and the
|
||||
**File ▸ Publish to server…** (or **Share ▸ Advanced ▸ Publish to another server…**) asks for that address and the
|
||||
file's name. The open file is uploaded as a new file and the tab keeps syncing through the
|
||||
server; until the upload is done the tab is read-only ("Publishing…" in the toolbar). The Share
|
||||
popover then shows an **edit link** and a **view link** to copy. Agents do the same with
|
||||
@@ -67,13 +67,49 @@ who runs it ("Hosted by …" and a link to its terms) and how long unopened file
|
||||
then also gives an **owner token**, shown once with the links in a **File published** window: copy
|
||||
it and keep it, it is the only way to revoke the links, rename the file or remove it later
|
||||
(`collab.manageFile` from [MCP](mcp.md#live-sessions)). The Share popover shows the server's line
|
||||
and the date the server deletes the file unless someone opens it. **Preferences ▸ Server to publish
|
||||
to** fills the address in (empty by default: the app suggests no server of its own).
|
||||
and the date the server deletes the file unless someone opens it.
|
||||
|
||||
## Sharing with a join code
|
||||
|
||||
**Share ▸ Online ▸ Start sharing** in the app (`collab.share`) is the short way to do all of the
|
||||
above on a server open to the public: it publishes the open file to the server in **Preferences ▸
|
||||
Server for online sharing** (the FigCraft server unless you put yours there; Reset goes back; empty
|
||||
turns online sharing off), then shows a **join code** such as `K7M-2QX-9TD` in place of the links.
|
||||
Someone else types it under **Share ▸ Join with a code** (or on the home screen, or
|
||||
`collab.join {code}`) with the same server in their Preferences, and is in the file. The popover
|
||||
switches between the code that lets people edit and the one that only lets them view, and **Stop
|
||||
sharing** removes the file from the server (with its owner token), so its codes and links stop
|
||||
working; everyone keeps the copy they have. To use **your** server for this, set its address
|
||||
(`wss://figcraft.example.com/`) in Preferences on every machine that shares or joins; a server
|
||||
that needs its key is reached under **Advanced ▸ Publish to another server…**, or with
|
||||
`collab.share {server: "wss://…/?key=…"}`, and gets codes too.
|
||||
|
||||
- **A code is a name for one share link**: nine symbols, 45 random bits, from an alphabet without
|
||||
look-alike characters (typing ignores case, spaces and dashes). The server hands one out when
|
||||
whoever manages the file asks (the app does, as the file's owner, or with the server key), one
|
||||
per link, so the view code and the edit code differ. Typing a code gives the joiner's app the
|
||||
link's token and it joins with that: access, reconnects and revocation are the link's.
|
||||
- **Nothing readable is kept**: `links.json` gets the code's stretched hash and the link's token
|
||||
wrapped with the code, never the code. A code goes when its link is revoked, its file is removed
|
||||
or expires, or a newer code is asked for the same link.
|
||||
- **Guessing is limited on every server**, public or private: 10 codes a minute per address; 10
|
||||
wrong in a row lock an address out of codes for 15 minutes ("Too many wrong codes from your
|
||||
address…"); and 1,000 wrong an hour from everyone together pause codes for all ("Join codes are
|
||||
paused on this server…") until the budget refills. Links are never affected. Refused codes are
|
||||
logged as `event=code_refused ip=… reason=…`, used ones as `event=code_used file=… ip=…`, new
|
||||
ones as `event=code file=… link=…`; the codes themselves are never logged.
|
||||
- **Upgrade note.** Join codes need a server of this version or newer, and so does everyone's app.
|
||||
An older server still takes files and links from a newer app (the app shows the links where the
|
||||
code would be, and says a typed code can't be asked there); a newer server serves older apps
|
||||
unchanged. To hand out codes, upgrade the server binary and restart it: the data directory needs
|
||||
no migration, existing files get codes the next time they are shared, and no setting turns codes
|
||||
on or off. Behind the reverse proxy nothing changes (codes travel inside the WebSocket).
|
||||
|
||||
## Joining
|
||||
|
||||
Share links look like the desktop app's invite links and join the same way (**File ▸ Join live
|
||||
session…**, opening the link, or `collab.join {link}`):
|
||||
A [join code](#sharing-with-a-join-code) is the short way in. Share links look like the desktop
|
||||
app's invite links and join the same way (the same **Join with a code** field takes them, as do
|
||||
**File ▸ Join live session…**, opening the link, and `collab.join {link}`):
|
||||
|
||||
- `figcraft://join?host=<host:port>&file=<id>&token=<token>` for a plain `ws://` server;
|
||||
- `figcraft://join?server=wss://figcraft.example.com/&file=<id>&token=<token>` for a server behind
|
||||
@@ -208,7 +244,8 @@ are errors):
|
||||
|
||||
With `listen` on every interface (`0.0.0.0`) and no `public_url`, links name this machine's
|
||||
network address. The limits marked *open* apply only with `open_publish = true`; a private server
|
||||
behaves as before (blocked addresses and report rates apply to every server).
|
||||
behaves as before (blocked addresses, report rates and the limits on
|
||||
[join codes](#sharing-with-a-join-code) apply to every server).
|
||||
|
||||
## The data directory
|
||||
|
||||
@@ -217,7 +254,8 @@ behaves as before (blocked addresses and report rates apply to every server).
|
||||
<data>/server.json where the running server listens (read by the commands)
|
||||
<data>/server.key the server key
|
||||
<data>/files/<id>/meta.json the file's name, when it was created and last changed
|
||||
<data>/files/<id>/links.json its links: id, access, label, created, SHA-256 of the token
|
||||
<data>/files/<id>/links.json its links: id, access, label, created, SHA-256 of the token, and for
|
||||
a link with a join code its stretched hash and the wrapped token
|
||||
<data>/files/<id>/snapshot.bin its state at the last compaction (with a checksum)
|
||||
<data>/files/<id>/log.bin what changed since, one checksummed record per change
|
||||
<data>/files/<id>/library.bin its team library, when one was published (a .figcraft archive)
|
||||
@@ -353,10 +391,11 @@ What someone can do, by what they hold (the server is reached through TLS at the
|
||||
|
||||
| Who | Can | Cannot |
|
||||
|---|---|---|
|
||||
| Anyone who reaches the server, with nothing | Ask about its hosting (open, operator, terms, limits); on an open server publish files within the limits per address and for everyone; list and download the plugin directory; ask `/healthz` | Join, read or list any file; see links, reports or blocked addresses; use or learn the server key or anyone's token (none are ever sent back, and all are compared in constant time against hashes); publish plugins; spoof their address through `X-Forwarded-For` (believed only from `trusted_proxy`); get past a block |
|
||||
| Anyone who reaches the server, with nothing | Ask about its hosting (open, operator, terms, limits); on an open server publish files within the limits per address and for everyone; list and download the plugin directory; ask `/healthz`; try join codes, within the limits on wrong ones | Join, read or list any file; see links, reports or blocked addresses; use or learn the server key or anyone's token (none are ever sent back, and all are compared in constant time against hashes); publish plugins; spoof their address through `X-Forwarded-For` (believed only from `trusted_proxy`); get past a block |
|
||||
| Someone with a **join code** | Exactly what its link gives (a view code: the view link's row; an edit code: the edit link's), until the code or its link is gone | Get a code for any other link; learn anything from a wrong code but that it is wrong |
|
||||
| Someone with a file's **view link** | See that file and everyone's changes and presence in it; read its team library and usage; report it | Change it (the server refuses a viewer's edits); reach any other file; manage links |
|
||||
| Someone with a file's **edit link** | Change that file, within its size cap; publish its team library (on a public server only for files published with the key); report it | Reach any other file; grow it past `max_file_mb` or its publisher's share; list, make or revoke links; rename or remove it |
|
||||
| Whoever holds a file's **owner token** (published without the key) | List, make and revoke that file's links (disconnecting whoever joined with a revoked one), rename it on the server, remove it | Touch any other file, even with its id; list files; read reports; block; anything of the plugin directory |
|
||||
| Whoever holds a file's **owner token** (published without the key) | List, make and revoke that file's links (disconnecting whoever joined with a revoked one), ask for join codes for links whose token they hold, rename it on the server, remove it | Touch any other file, even with its id; list files; read reports; block; anything of the plugin directory |
|
||||
| The **operator** (server key, or the machine) | Everything above for every file, reports, blocks, the plugin directory; read every file on disk | Recover a lost link or owner token (only hashes are kept) |
|
||||
|
||||
Limits that hold against everyone: message sizes by what a connection is for, file sizes as they
|
||||
@@ -574,7 +613,7 @@ sudo ufw enable
|
||||
```
|
||||
|
||||
**6. Publish and share.** Print the publish address (`wss://figcraft.example.com/?key=…`) and use
|
||||
it in the app's **Share ▸ Publish to server…** (see [Publishing a file](#publishing-a-file)):
|
||||
it in the app's **File ▸ Publish to server…** (see [Publishing a file](#publishing-a-file)):
|
||||
|
||||
```sh
|
||||
sudo -u figcraft figcraft-server --config /etc/figcraft-server/server.toml key
|
||||
@@ -626,6 +665,13 @@ sudo systemctl start figcraft-server
|
||||
Tokens are stored as SHA-256 hashes and every comparison runs in constant time; revoking a link
|
||||
disconnects whoever joined with it. A view link's batches are refused by the server whatever the
|
||||
client does.
|
||||
- **Join codes** are 45 random bits naming one link: far easier to guess than a link, so every
|
||||
server limits wrong codes per address and for everyone
|
||||
([Sharing with a join code](#sharing-with-a-join-code)); with those limits, finding any one of
|
||||
20,000 live codes takes on the order of centuries, while a flood of wrong codes can pause codes
|
||||
(never links) for a while. Codes are kept only as a stretched hash and compared in constant
|
||||
time; the link's token is kept wrapped with the code. A copy of the data directory lets someone
|
||||
try codes offline (10,000 hashes a guess), and it holds the files themselves anyway.
|
||||
- **Open to the public** (`open_publish`): anyone who reaches the server may ask about it, publish
|
||||
a file (within the limits above) and report a file they are in; an owner token manages only its
|
||||
own file (its links, its name, removing it). Listing files, other files' links, reports, blocking
|
||||
|
||||
Reference in New Issue
Block a user