Compare commits
105 Commits
feat/layer
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
5e90592e80 | |
|
|
ef2c6cb30e | |
|
|
24c5617230 | |
|
|
fb8ace5bf4 | |
|
|
ce84d1f27f | |
|
|
8e12e5eeda | |
|
|
118ca7c60b | |
|
|
d61ccc633a | |
|
|
badcb6ec21 | |
|
|
7a9a67a8c3 | |
|
|
dd3d4d326c | |
|
|
6d735653a2 | |
|
|
ac65168417 | |
|
|
f7e726714a | |
|
|
7d589c4456 | |
|
|
79a0e58a3c | |
|
|
db55720c93 | |
|
|
0efbd55564 | |
|
|
5b52eeeb36 | |
|
|
8b7435890e | |
|
|
33dcb1aef6 | |
|
|
e8d7dc24f2 | |
|
|
d30255bf62 | |
|
|
01904ce2fd | |
|
|
e56856f401 | |
|
|
e02ad435b3 | |
|
|
a399ff8934 | |
|
|
d7f76246d3 | |
|
|
fca70a6063 | |
|
|
2c35c7bd21 | |
|
|
5548284c65 | |
|
|
a2a0f80252 | |
|
|
1667b7bc91 | |
|
|
ac6adc2d54 | |
|
|
c910060f36 | |
|
|
3efff16df2 | |
|
|
03b173fd98 | |
|
|
794b83a0db | |
|
|
511213e820 | |
|
|
9c9ccecbc5 | |
|
|
5be3012e9b | |
|
|
573dd521bb | |
|
|
c2a67f81e5 | |
|
|
31815f1d13 | |
|
|
501e4a6737 | |
|
|
517b1a02ce | |
|
|
39e9e87b74 | |
|
|
ce094f397a | |
|
|
485715f11e | |
|
|
c5830e4683 | |
|
|
c7771c7bb7 | |
|
|
8f6a123f29 | |
|
|
6720ea4a21 | |
|
|
f77d9a467e | |
|
|
94349d86b0 | |
|
|
0e6dc6871b | |
|
|
2cf4510cbd | |
|
|
47fff74655 | |
|
|
1610d3db86 | |
|
|
76929f63ce | |
|
|
8df9998044 | |
|
|
16fe57e94f | |
|
|
a0d1e72c8d | |
|
|
ab94cff913 | |
|
|
9fde7fed3a | |
|
|
98c4753118 | |
|
|
e0435d4eb4 | |
|
|
7edc61b8d2 | |
|
|
faf2e78c9d | |
|
|
b7af7c7ef7 | |
|
|
f2802cca02 | |
|
|
d988a30607 | |
|
|
e01f4c1a43 | |
|
|
6d1804ddc5 | |
|
|
29e31c2054 | |
|
|
c420721597 | |
|
|
8d5f8a0978 | |
|
|
01bc5a9dac | |
|
|
9f13cb4ba2 | |
|
|
08df188e1a | |
|
|
119b8c7441 | |
|
|
def6f84b22 | |
|
|
159de3ce43 | |
|
|
207373be15 | |
|
|
1e0b51398a | |
|
|
1f6e75e0b6 | |
|
|
626a9eac08 | |
|
|
a5a706674c | |
|
|
7e467c2b82 | |
|
|
8a983f42ce | |
|
|
c50bbd08bd | |
|
|
970eb001f1 | |
|
|
bf82f7ebf9 | |
|
|
1caf771e82 | |
|
|
f2c22213d6 | |
|
|
8b2607de8e | |
|
|
df6bc0a319 | |
|
|
2a54adb762 | |
|
|
a2602071d7 | |
|
|
e917d3c2e6 | |
|
|
f3012ce6e4 | |
|
|
b65cc2e4fe | |
|
|
62db8d4e36 | |
|
|
5923e7dfcf | |
|
|
cc30a261e7 |
|
|
@ -0,0 +1,4 @@
|
|||
# Scripts and server files run on Linux: keep their line endings LF in every checkout and archive
|
||||
# (a copy of a Windows checkout must still run packaging/server/install.sh).
|
||||
*.sh text eol=lf
|
||||
packaging/server/* text eol=lf
|
||||
|
|
@ -35,6 +35,8 @@ jobs:
|
|||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Server install script (Caddyfile handling)
|
||||
run: bash packaging/server/install-test.sh
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
- name: PowerShell
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ jobs:
|
|||
- name: Check the archive
|
||||
run: |
|
||||
tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt"
|
||||
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/server\.md$' '/LICENSE-MIT$'; do
|
||||
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do
|
||||
grep -E "$f" "$RUNNER_TEMP/server-contents.txt"
|
||||
done
|
||||
ldd target/release/figcraft-server
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ code.
|
|||
`assets/`, `docs/brand/` or `docs/images/`.
|
||||
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
|
||||
are original. Keep it that way: new icons are new code, designed from scratch.
|
||||
- The app icon is generated in code (`cargo xtask icon`, `xtask/src/icon.rs`).
|
||||
- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
|
||||
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
|
||||
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ MIT OR Apache-2.0. No third-party icon sets are used.
|
|||
|
||||
| Path | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `assets/app-icon/` (all files: `figcraft-*.png`, `figcraft.ico`, `hicolor/*/apps/ai.storyteller.figcraft.png`) | FigCraft contributors | Generated by `cargo xtask icon` (`xtask/src/icon.rs`) with FigCraft's own renderer | MIT OR Apache-2.0 | Original geometric mark (stacked layer cards and a cursor) |
|
||||
| `assets/app-icon/` (all files: `figcraft.svg`, `figcraft-small.svg`, `figcraft-*.png`, `figcraft.ico`, `hicolor/`) | The project owner (engraving-style drawing, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md); derived files rendered by `cargo xtask icon` | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | FigCraft app icon (chameleon) |
|
||||
| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only |
|
||||
| `docs/images/` (all screenshots) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft | MIT OR Apache-2.0 | No third-party artwork |
|
||||
| `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork |
|
||||
| `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork |
|
||||
|
||||
## Fonts (build input, not in this repository)
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ code.
|
|||
`assets/`, `docs/brand/` or `docs/images/`.
|
||||
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
|
||||
are original. Keep it that way: new icons are new code, designed from scratch.
|
||||
- The app icon is generated in code (`cargo xtask icon`, `xtask/src/icon.rs`).
|
||||
- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
|
||||
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
|
||||
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`
|
||||
|
|
|
|||
|
|
@ -1407,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
|||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
|
|
@ -1422,6 +1422,7 @@ dependencies = [
|
|||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"wat",
|
||||
"webbrowser",
|
||||
"windows",
|
||||
"winresource",
|
||||
|
|
@ -1429,7 +1430,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
|
|
@ -1443,7 +1444,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
|
|
@ -1458,7 +1459,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
|
|
@ -1481,7 +1482,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
|
|
@ -1490,7 +1491,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
|
|
@ -1502,7 +1503,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
|
|
@ -1519,6 +1520,7 @@ dependencies = [
|
|||
"figcraft-text",
|
||||
"image",
|
||||
"log",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -1528,9 +1530,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
"flate2",
|
||||
"image",
|
||||
"ruzstd",
|
||||
|
|
@ -1542,7 +1545,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
|
|
@ -1550,7 +1553,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
|
|
@ -1558,7 +1561,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
|
|
@ -1574,7 +1577,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
|
|
@ -1583,7 +1586,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
|
|
@ -1600,11 +1603,12 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"wasmi",
|
||||
"wat",
|
||||
|
|
@ -1612,7 +1616,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
|
|
@ -1622,13 +1626,15 @@ dependencies = [
|
|||
"figcraft-text",
|
||||
"image",
|
||||
"log",
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
|
|
@ -1638,17 +1644,21 @@ dependencies = [
|
|||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
"figcraft-plugin",
|
||||
"log",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"tungstenite",
|
||||
"wat",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
|
|
@ -1657,12 +1667,14 @@ dependencies = [
|
|||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-text",
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
|
|
@ -1675,7 +1687,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
|
|
@ -1698,7 +1710,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -3913,9 +3925,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
|
|
@ -5794,14 +5806,9 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-render",
|
||||
"figcraft-svg",
|
||||
"image",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ exclude = ["plugins"]
|
|||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
@ -37,6 +37,7 @@ kurbo = { version = "0.13", features = ["serde"] }
|
|||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
log = "0.4"
|
||||
proptest = "1"
|
||||
imbl = { version = "7", features = ["serde"] }
|
||||
|
|
@ -45,6 +46,9 @@ postcard = { version = "1", default-features = false, features = ["alloc"] }
|
|||
# rendering / formats
|
||||
tiny-skia = "0.12"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
# Already built for `image`: embedded ICC profiles (moxcms) and PNG colour chunks (png).
|
||||
moxcms = "0.8"
|
||||
png = "0.18"
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
|
|
@ -55,7 +59,7 @@ linesweeper = "0.4"
|
|||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
crc32fast = "1"
|
||||
|
|
|
|||
126
README.md
|
|
@ -34,11 +34,20 @@
|
|||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/shot-1-hero.png" alt="FigCraft in its dark theme editing the Ridgeline hiking app: three phone screens (Home in light mode, a trail detail page and Home in dark mode) on the canvas, the Featured trail card instance selected, the layers panel showing the screen's auto layout tree, and the Design panel showing the instance's Fill and Hug auto layout sizing, its Theme variable mode and a fill bound to the surface colour variable" width="100%">
|
||||
<br><sub><b>Ridgeline</b>: auto layout screens built from components, with Light and Dark modes from one set of colour variables · <code>examples/ridgeline-app.figcraft</code></sub>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
> [!NOTE]
|
||||
> **ArtCraft is a community of artists from all walks of life.** Designers, illustrators, developers,
|
||||
> musicians, game makers — if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
|
||||
|
||||
<p align="center">
|
||||
<a href="#a-look-around">A look around</a> ·
|
||||
<a href="#made-in-figcraft">Made in FigCraft</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#built-for-agents">Agents</a> ·
|
||||
<a href="#under-the-hood">Under the hood</a> ·
|
||||
|
|
@ -61,7 +70,7 @@
|
|||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🔌 Offline and private</h3>
|
||||
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no cloud, no telemetry. SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
|
||||
Your files are plain <code>.figcraft</code> archives on your own disk. No account, no telemetry, and nothing leaves your computer unless you share a file (<a href="#privacy-and-online-sharing">what sharing sends where</a>). SVG, PNG, JPG, WebP and PDF in and out; <code>.fig</code> files open (best effort), and so do designs exported as the public REST API file JSON, with their prototypes and variables.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🤖 Agent-ready</h3>
|
||||
|
|
@ -72,6 +81,86 @@
|
|||
|
||||
<br>
|
||||
|
||||
## A look around
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-2-components.png" alt="The Ridgeline UI design system open with the Assets panel on the left showing live thumbnails of the Button, Difficulty, Search field, Toggle and icon components; a Button component set of nine variants laid out in a three-by-three grid auto layout is selected, and the Design panel lists its Style (Primary, Tonal, Outline) and State (Default, Hover, Disabled) variant properties" width="100%">
|
||||
<p align="center"><sub><b>Components and variants</b>: component sets, variant properties and the Assets panel · <code>examples/ridgeline-design-system.figcraft</code></sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-3-prototype.png" alt="Four onboarding phone screens in the Prototype tab, joined by blue connection curves from the Get started, Continue, Allow location and Not now buttons to the next screens; the Get started button is selected and the Prototype panel shows its On click, Navigate to 2 Interests interaction" width="100%">
|
||||
<p align="center"><sub><b>Prototyping</b>: connections, transitions, back actions and a flow · <code>examples/ridgeline-onboarding.figcraft</code></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-4-devmode.png" alt="Dev Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows generated React code with a TrailRow props interface and the instance's title and details values" width="100%">
|
||||
<p align="center"><sub><b>Dev Mode</b>: ready-for-dev status, annotations and React code where component properties become props · <code>examples/ridgeline-app.figcraft</code></sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-5-vector.png" alt="Vector edit mode on a wave path with a gradient, tapered variable-width stroke: its anchor points, Bezier handles and the transform box of the selected points are visible, with the vector edit toolbar at the bottom; above it are a boolean-built mountain mark and an angular-gradient knot of excluded rings" width="100%">
|
||||
<p align="center"><sub><b>Vector networks</b>: points and handles, variable-width strokes, booleans that stay editable · <code>examples/glass-and-vectors.figcraft</code></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-6-dashboard-light.png" alt="The light UI theme with a web analytics dashboard for a fictional trail operator: a dark sidebar, KPI cards with sparklines, a grouped bar chart, a donut chart, a table of top trails and an area chart, with the frame's 12-column layout grid showing as tinted columns and its fill linked to a paint style" width="100%">
|
||||
<p align="center"><sub><b>Layout grids and styles, light theme</b>: a 12-column grid, paint, text, effect and grid styles, charts drawn from shapes · <code>examples/ridgeline-studio-dashboard.figcraft</code></sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-7-collab.png" alt="A live session seen from one participant's window: avatars of three people in the top right, a Priya Nair is spotlighting banner with a Follow button, Priya's green cursor and selection outline on the dark home screen's trail card, and Jonas Ortiz's purple cursor and selection on the Start hike button with his cursor chat bubble asking whether the button should stay pinned while scrolling" width="100%">
|
||||
<p align="center"><sub><b>Live sessions</b>: three apps on one <code>figcraft-server</code> file, with cursors, selections, cursor chat and spotlight</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Made in FigCraft
|
||||
|
||||
Every design below was built entirely through FigCraft's command API, the same one the MCP server
|
||||
exposes to agents, and exported at 2x by FigCraft's own renderer. The source files are in
|
||||
[`examples/`](examples).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<img src="docs/images/art-app-home.png" alt="Ridgeline home screen in light mode: a greeting, a search field, filter chips, a featured trail card with an illustrated mountain landscape, a list of nearby trails and a translucent tab bar" width="100%">
|
||||
<p align="center"><sub><b>Home, light</b></sub></p>
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<img src="docs/images/art-app-trail.png" alt="Ridgeline trail detail screen: a dusk mountain illustration with glass buttons, a sheet with difficulty tags, the trail name, distance, elevation and duration stats, an elevation profile chart and a Start hike button" width="100%">
|
||||
<p align="center"><sub><b>Trail detail</b></sub></p>
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<img src="docs/images/art-app-home-dark.png" alt="The same Ridgeline home screen in dark mode, its colours switched by the Theme variable collection's Dark mode" width="100%">
|
||||
<p align="center"><sub><b>Home, dark</b>: the same frame in the Dark mode</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" valign="top">
|
||||
<img src="docs/images/art-dashboard.png" alt="Ridgeline Studio dashboard: a dark navy sidebar with navigation, KPI cards for active hikers, check-ins, average rating and open incidents with sparklines, a hikes-per-day grouped bar chart with a tooltip, a difficulty donut chart, a top trails table with condition badges and a visitors area chart" width="100%">
|
||||
<p align="center"><sub><b>Ridgeline Studio</b>: a desktop dashboard on a 12-column grid, every chart made of shapes and vectors</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<img src="docs/images/art-glass-weather.png" alt="A glass weather widget over blurred teal, violet, rose and orange colour fields: a frosted card for Lake Louise showing 14 degrees and partly cloudy with a gradient sun behind a boolean-union cloud, an hourly forecast row, and smaller glass cards for air quality, wind and humidity" width="100%">
|
||||
<p align="center"><sub><b>Glass weather</b>: glass and background blur, layer-blurred colour fields, a boolean cloud</sub></p>
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<img src="docs/images/art-vector-marks.png" alt="Three tiles on a cream background: a gradient mountain mark made by unioning two peaks and subtracting a sun, a knot of three excluded rings with an angular gradient, and a tapered gradient wave with a leaf and a glowing four-point star" width="100%">
|
||||
<p align="center"><sub><b>Vector marks</b>: union, subtract and exclude, an angular gradient, a variable-width stroke</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" valign="top">
|
||||
<img src="docs/images/art-ds-colour.png" alt="Ridgeline UI colour ramps: six swatches each of Pine greens, Sunset oranges and Slate greys, each labelled with its name and hex value" width="100%">
|
||||
<p align="center"><sub><b>Ridgeline UI</b>: colour ramps where every swatch is a paint style, exported through a slice</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Features
|
||||
|
||||
<table>
|
||||
|
|
@ -102,7 +191,7 @@
|
|||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Prototype and hand off</h3>
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -140,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.
|
||||
|
||||
|
|
@ -162,6 +251,10 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
|
|||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
```
|
||||
|
||||
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
|
||||
|
||||
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
|
||||
|
||||
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
|
||||
|
||||
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
|
||||
|
|
@ -171,6 +264,31 @@ Installers for macOS (universal, signed and notarized), Windows (x64, x86 and ar
|
|||
> [!IMPORTANT]
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Privacy and online sharing
|
||||
|
||||
FigCraft works offline: it has no account and no telemetry, and it contacts no server until you
|
||||
share or join a file. Live sessions are a **preview**. Before you share anything that matters,
|
||||
know what sharing does:
|
||||
|
||||
- **Share ▸ Online uploads the file to a server.** By default that is the public server at
|
||||
`collab.craftapps.dev`; you can set another one, or your own, in Preferences (and a build can
|
||||
ship without it: [`docs/releasing.md`](docs/releasing.md#the-default-server)). The file stays on
|
||||
the server after you stop sharing, until you remove it (Share ▸ Advanced ▸ Remove from server…)
|
||||
or nobody has opened it for 30 days, when a public server deletes it.
|
||||
- **It is not end-to-end encrypted.** The connection to the server is encrypted (TLS), but the
|
||||
server stores the file unencrypted and whoever runs the server can read it. Don't share
|
||||
confidential work through a server you don't trust; run your own
|
||||
([`docs/server.md`](docs/server.md)) or share on the local network instead.
|
||||
- **The code or link is the access.** There are no accounts: anyone who has an edit code or link
|
||||
can open and change the file, anyone with a view code or link can open it. Stop sharing revokes
|
||||
them and disconnects everyone else.
|
||||
- **Share ▸ Local network involves no server**, but it is not encrypted either: your app hosts
|
||||
the session and others connect to it directly, so use it on networks you trust.
|
||||
- **No guarantees.** The public server is offered as is, without backups or uptime promises, and
|
||||
files on it can be removed. Keep your own saved copy of anything you share.
|
||||
|
||||
The default server's terms are in [`docs/online-sharing-terms.md`](docs/online-sharing-terms.md).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
FigCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
|
|
|
|||
268
ROADMAP.md
|
|
@ -1,8 +1,8 @@
|
|||
# FigCraft Roadmap
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
|
||||
**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-11 · **Change:** minor (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-14 · **Change:** minor (SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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
|
||||
|
|
@ -13,21 +13,21 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (204 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~80%** (core editor ~91%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~49%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~46%** | estimated: weekly areas 67.8% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~173–310 h** one agent | estimated |
|
||||
| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
|
||||
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~155–280 h** one agent | estimated |
|
||||
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~49% | ~305–584 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
|
||||
| Mainstream practitioner | ~46% | ~140–259 h | core editor depth incl. the `.fig` import's tail (58–112), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
|
||||
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
|
||||
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
|
||||
|
||||
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
|
||||
per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
|
||||
|
|
@ -46,52 +46,52 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human:
|
|||
**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components
|
||||
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
|
||||
FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked
|
||||
against real Figma documents. Ready-for-real-work is ~49%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
|
||||
~75% **and** reliable `.fig` import: the `.fig` import's tail (8–15 h), a real-file corpus with measured
|
||||
against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
|
||||
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
|
||||
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
|
||||
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h) and
|
||||
Figma's own languages on the landed localization layer (15–25 h).
|
||||
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
|
||||
native speakers' reviews of the six language drafts need people.
|
||||
|
||||
## By dimension
|
||||
|
||||
| Dimension | Ready | Hours | Doc |
|
||||
|---|---|---|---|
|
||||
| Features (depth) | 57% (core editor 68%) | 60–115 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 20% (layer; English, Spanish draft) | 45–75 | [localization-parity](docs/localization-parity.md) |
|
||||
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 28% (own WASM plugins with panels and settings) | 15–40 | [gaps](docs/gaps.md) |
|
||||
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Breadth | Ready | Hours | Doc |
|
||||
|---|---|---|---|---|
|
||||
| Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) |
|
||||
| Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) |
|
||||
| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) |
|
||||
| Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) |
|
||||
| Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Strokes | 90% | 72% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) |
|
||||
| Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) |
|
||||
| Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) |
|
||||
| Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) |
|
||||
| Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) |
|
||||
| Import/export | 65% | 35% | 30–60 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Dev Mode / inspect | 90% | 64% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) |
|
||||
| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
|
||||
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
|
||||
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
|
||||
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
|
||||
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||
| Figma Draw extras | 80% | 55% | 2–5 | [gaps](docs/gaps.md) |
|
||||
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
||||
## Languages
|
||||
|
|
@ -99,35 +99,150 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | en | 100% | full |
|
||||
| Simplified Chinese | zh-Hans | 0% | none |
|
||||
| Spanish | es | 100% | draft (needs native review) |
|
||||
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 0% | none |
|
||||
| French | fr | 0% | none |
|
||||
| Portuguese | pt | 0% | none |
|
||||
| French | fr | 100% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 0% | none |
|
||||
| German | de | 0% | none |
|
||||
| Korean | ko | 0% | none |
|
||||
| Japanese | ja | 100% | draft (needs native review) |
|
||||
| German | de | 100% | draft (needs native review) |
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and a Spanish draft (Preferences ▸ Language;
|
||||
adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic
|
||||
shaping, IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
1. `.fig` import's tail: editable vector networks, the skipped layer kinds that matter, fixes from real files (the best-effort import landed 2026-10-11): 8–15 h.
|
||||
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
|
||||
2. Real-file corpus and measured fidelity: 15–30 h.
|
||||
3. Tool-by-tool interaction pass against Figma: 40–60 h.
|
||||
4. Languages on the landed localization layer: ja, ko, pt-BR, fr, de, and a native review of es: 15–30 h.
|
||||
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 45–90 h.
|
||||
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
|
||||
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
|
||||
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
|
||||
- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged.
|
||||
- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged.
|
||||
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
|
||||
- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged.
|
||||
- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
|
||||
- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged.
|
||||
- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged.
|
||||
- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged.
|
||||
- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged.
|
||||
- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged.
|
||||
- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from
|
||||
their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as
|
||||
derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings,
|
||||
the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the
|
||||
import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width;
|
||||
number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged.
|
||||
text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged.
|
||||
- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page,
|
||||
dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a
|
||||
Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting
|
||||
started card that can be closed and comes back from the Help menu
|
||||
- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app
|
||||
quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online**
|
||||
lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from
|
||||
the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a
|
||||
crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an
|
||||
upgrade only to tell which files are still there. The six draft catalogs translate every string again
|
||||
- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge
|
||||
preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and
|
||||
object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are
|
||||
kept, and dark pages (white text on them) for files made in the dark theme
|
||||
- **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
|
||||
in the design-system example). One font family; real files still unchecked (gap 3). No number moves
|
||||
- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at
|
||||
once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover,
|
||||
people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`),
|
||||
and closing a file ends its session only (before, one session followed every tab and starting another replaced it)
|
||||
- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key
|
||||
(Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are
|
||||
kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address,
|
||||
idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and
|
||||
`packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups
|
||||
([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature).
|
||||
- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other
|
||||
software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The
|
||||
export now says that the document and its pages are visible and opaque, numbers its schema as the format does,
|
||||
and writes outlines, text settings, standard font style names and line height units; effects such as glass and
|
||||
noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers
|
||||
unchanged.
|
||||
- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig
|
||||
(experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers,
|
||||
geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and
|
||||
instances with their overrides, and prototype interactions; what's left out (style links, variables, comments,
|
||||
Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File
|
||||
formats 60%.
|
||||
- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129
|
||||
shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it
|
||||
is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on
|
||||
Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing,
|
||||
keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control
|
||||
suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on
|
||||
Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main
|
||||
component keys; shifted symbols on other keyboard layouts.
|
||||
- **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key
|
||||
(`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a
|
||||
confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are
|
||||
checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live
|
||||
per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with
|
||||
`figcraft-server plugins`. Plugins & widgets 60%.
|
||||
- **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget
|
||||
plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin,
|
||||
validated and built with locked layers inside; clicking a part it named calls it back with the widget's state
|
||||
(kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws;
|
||||
`widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and
|
||||
plugin colour fields open the colour picker. Plugins & widgets 55%.
|
||||
- **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land
|
||||
together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers
|
||||
at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start
|
||||
value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names,
|
||||
and hidden-from-publishing names that decide both ways.
|
||||
- **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit
|
||||
mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several
|
||||
Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per
|
||||
point, and the Pen connecting to its own points or continuing a selected open path.
|
||||
- **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel
|
||||
grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide,
|
||||
Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames),
|
||||
outline mode (clicks reach layers behind) and measuring, each rule from the documentation.
|
||||
- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows
|
||||
only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under
|
||||
them, layer names with slashes export into folders; export settings gained contents only, include bounding box and
|
||||
the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in
|
||||
rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%.
|
||||
- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their
|
||||
corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes
|
||||
with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped;
|
||||
set-variable and conditional prototype actions come across. File formats 58%, ready ~49%.
|
||||
- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes,
|
||||
gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal
|
||||
groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent
|
||||
layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through.
|
||||
Paint 70%, effects 75%.
|
||||
- **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and
|
||||
MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name
|
||||
onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and
|
||||
|
|
@ -195,6 +310,57 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged |
|
||||
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
|
||||
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
|
||||
| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged |
|
||||
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains |
|
||||
| 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 others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **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 |
|
||||
| 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 |
|
||||
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding |
|
||||
| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding |
|
||||
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h |
|
||||
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h |
|
||||
| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h |
|
||||
| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h |
|
||||
| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h |
|
||||
| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h |
|
||||
| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h |
|
||||
| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h |
|
||||
| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding |
|
||||
| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) |
|
||||
| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h |
|
||||
| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h |
|
||||
| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) |
|
||||
| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) |
|
||||
| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h |
|
||||
| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “<branch>”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h |
|
||||
| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h |
|
||||
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
|
||||
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) |
|
||||
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
|
||||
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |
|
||||
| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h |
|
||||
| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h |
|
||||
| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) |
|
||||
| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h |
|
||||
| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h |
|
||||
| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h |
|
||||
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
//! Windows: give the main thread the stack Linux and macOS give it (see `main_thread_stack`).
|
||||
fn main() {
|
||||
main_thread_stack();
|
||||
}
|
||||
|
||||
/// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers nest up
|
||||
/// to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
fn main_thread_stack() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use figcraft_doc::{Document, ExportFormat, NodeId, NodeKind, PrintSettings};
|
||||
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -102,22 +102,37 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
|||
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
|
||||
}
|
||||
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
|
||||
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.map_err(|e| CliError::Failed(e.to_string()))?
|
||||
.into_bytes(),
|
||||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf)"))),
|
||||
"fig" => fig_bytes(doc)?,
|
||||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
|
||||
};
|
||||
write(out, &bytes)
|
||||
}
|
||||
|
||||
/// `doc` as a `.fig` file (experimental), printing what was left out.
|
||||
fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
|
||||
let (bytes, warnings) = figcraft_engine::cmd::file::fig_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
for w in warnings {
|
||||
eprintln!("note: {w}");
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
|
||||
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
|
||||
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
|
||||
figcraft_engine::cmd::file::frames_pdf(doc, page, print).map(|(bytes, _)| bytes)
|
||||
} else {
|
||||
figcraft_engine::cmd::file::export_bytes_with(doc, &children(doc, page), ExportFormat::Pdf, 1.0, print)
|
||||
figcraft_engine::cmd::file::export_bytes_with(
|
||||
doc,
|
||||
&children(doc, page),
|
||||
&ExportSetting { format: ExportFormat::Pdf, print: *print, ..Default::default() },
|
||||
1.0,
|
||||
)
|
||||
};
|
||||
r.map_err(|e| CliError::Failed(e.to_string()))
|
||||
}
|
||||
|
|
@ -193,16 +208,26 @@ pub struct Target {
|
|||
|
||||
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
let doc = load(file)?;
|
||||
// `.fig`: the whole file.
|
||||
if t.format.as_deref().map_or(ext(out) == "fig", |f| f.eq_ignore_ascii_case("fig")) {
|
||||
write(out, &fig_bytes(&doc)?)?;
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
let page = page_named(&doc, t.page.as_deref())?;
|
||||
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
|
||||
let fmt = match t.format.as_deref() {
|
||||
Some(f) => image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf)")))?,
|
||||
Some(f) => {
|
||||
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
|
||||
}
|
||||
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
|
||||
};
|
||||
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
|
||||
let single = image_format(&ext(out)).is_some();
|
||||
let bytes_of =
|
||||
|ids: &[NodeId]| figcraft_engine::cmd::file::export_bytes_with(&doc, ids, fmt, scale, print).map_err(|e| CliError::Failed(e.to_string()));
|
||||
let bytes_of = |ids: &[NodeId]| {
|
||||
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
|
||||
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
|
||||
};
|
||||
if single {
|
||||
let bytes = match target {
|
||||
Some(t) => bytes_of(&[t])?,
|
||||
|
|
@ -354,4 +379,31 @@ mod tests {
|
|||
assert_eq!(sanitize("../.."), "_");
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_preserve_conversion_destinations() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..257 {
|
||||
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
|
||||
panic!("depth overflow must be an operation failure");
|
||||
};
|
||||
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
|
||||
//! command scripts, list commands, serve MCP on stdio and measure performance.
|
||||
//! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin
|
||||
//! directory and measure performance.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod args;
|
||||
|
|
@ -16,22 +17,31 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
|
|||
|
||||
USAGE:
|
||||
figcraft-cli info <file> [--json]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT]
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version
|
||||
|
||||
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
|
||||
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
|
||||
is given; PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
||||
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
|
|
@ -66,6 +76,13 @@ impl From<String> for CliError {
|
|||
pub type CliResult<T> = Result<T, CliError>;
|
||||
|
||||
fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
|
||||
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
|
||||
if system_fonts {
|
||||
// Text lays out with the installed fonts, like the desktop app, so headless renders and
|
||||
// auto layout sizes match what users see.
|
||||
figcraft_text::scan_system_fonts_on_demand();
|
||||
}
|
||||
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
|
||||
match cmd.as_str() {
|
||||
"help" | "--help" | "-h" => {
|
||||
|
|
@ -122,11 +139,21 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
|||
}
|
||||
mcp(a.value("attach").as_deref())
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[verb, folder] if verb == "publish" => {
|
||||
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
|
||||
publish_plugin(folder, &server)
|
||||
}
|
||||
_ => Err(CliError::Usage("expected: plugin publish <folder> --server ws://host:port/".into())),
|
||||
}
|
||||
}
|
||||
"perf" => {
|
||||
let a = Args::parse(rest, &["layers", "instances"], &[])?;
|
||||
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
|
||||
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
|
||||
perf::run(&sizes, &instances)
|
||||
perf::run(&sizes, &instances, system_fonts)
|
||||
}
|
||||
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
|
||||
}
|
||||
|
|
@ -155,6 +182,29 @@ fn commands(as_json: bool, filter: Option<&str>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Publish the plugin folder `folder` to the plugin directory at `server` (the key from the address
|
||||
/// or `FIGCRAFT_SERVER_KEY`); never prints the key.
|
||||
fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
|
||||
let address = server.split('?').next().unwrap_or_default().to_string();
|
||||
let server = match std::env::var("FIGCRAFT_SERVER_KEY") {
|
||||
Ok(key) if !server.contains("key=") && !key.trim().is_empty() => format!("{address}?key={}", key.trim()),
|
||||
_ => server.to_string(),
|
||||
};
|
||||
let mut h = figcraft_mcp::Headless::new();
|
||||
use figcraft_mcp::Backend as _;
|
||||
let r = h.call("engine.execute", json!({"command": "plugin.directory.publish", "params": {"path": folder, "server": server}}))?;
|
||||
let e = &r["published"];
|
||||
println!(
|
||||
"Published {} {} ({}) to {address}: {} bytes, SHA-256 {}",
|
||||
e["name"].as_str().unwrap_or_default(),
|
||||
e["version"].as_str().unwrap_or_default(),
|
||||
e["id"].as_str().unwrap_or_default(),
|
||||
e["bytes"],
|
||||
e["sha256"].as_str().unwrap_or_default()
|
||||
);
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
|
||||
Some(p) => {
|
||||
|
|
|
|||
|
|
@ -292,9 +292,11 @@ fn measure_boolean_drag(table: &mut Table, col: usize) {
|
|||
|
||||
/// Time the hot paths for each document size in `sizes` (layers) and, with the synthetic design
|
||||
/// system, for each instance count in `instances`; print the tables.
|
||||
pub fn run(sizes: &[usize], instances: &[usize]) -> CliResult<ExitCode> {
|
||||
// Text renders with system fonts too, like the desktop app.
|
||||
figcraft_text::scan_system_fonts();
|
||||
pub fn run(sizes: &[usize], instances: &[usize], system_fonts: bool) -> CliResult<ExitCode> {
|
||||
// Text renders with system fonts too, like the desktop app; scanned up front, outside the timings.
|
||||
if system_fonts {
|
||||
figcraft_text::scan_system_fonts();
|
||||
}
|
||||
let mut table = Table { rows: Vec::new() };
|
||||
for (col, n) in sizes.iter().enumerate() {
|
||||
eprintln!("measuring {n} layers…");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
//! Depth refusal through the actual CLI, including its exit status and destination writes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn export_depth_cli_refuses_partial_files_and_accepts_an_inner_root() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for depth in 1..257 {
|
||||
let name = if depth == 1 { "inner-root" } else { "group" };
|
||||
parent = doc.create(parent, NodeKind::Group, name, 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-depth-process-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let input = dir.join("deep.figcraft");
|
||||
std::fs::write(&input, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
for verb in ["convert", "export"] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
command.arg(verb).arg(&input);
|
||||
if verb == "export" {
|
||||
command.arg("-o");
|
||||
}
|
||||
let output = command.arg(&path).arg("--no-system-fonts").output().unwrap();
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("maximum export depth of 256"), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{verb} {ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
let inner = dir.join(format!("inner.{ext}"));
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
.arg("export")
|
||||
.arg(&input)
|
||||
.arg("-o")
|
||||
.arg(&inner)
|
||||
.args(["--node", "inner-root", "--no-system-fonts"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let bytes = std::fs::read(inner).unwrap();
|
||||
if ext == "svg" {
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("#FF0000"));
|
||||
} else {
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "no staging files after refusal");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
|
||||
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The result of one `figcraft-cli run` step (script file `name`); `opt_out` sets the environment
|
||||
/// variable.
|
||||
fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let script = d.join(format!("{name}.json"));
|
||||
std::fs::write(&script, json!([step]).to_string()).unwrap();
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
cmd.arg("run").arg(&script).args(args);
|
||||
if opt_out {
|
||||
cmd.env("FIGCRAFT_NO_SYSTEM_FONTS", "1");
|
||||
} else {
|
||||
cmd.env_remove("FIGCRAFT_NO_SYSTEM_FONTS");
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let line: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
line["result"].clone()
|
||||
}
|
||||
|
||||
fn families(args: &[&str], opt_out: bool) -> Vec<String> {
|
||||
serde_json::from_value(run_one("families", json!({"command": "text.fonts"}), args, opt_out)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_runs_resolve_installed_families_unless_opted_out() {
|
||||
let built_in = families(&["--no-system-fonts"], false);
|
||||
assert_eq!(families(&[], true), built_in, "FIGCRAFT_NO_SYSTEM_FONTS=1 opts out like the flag");
|
||||
// Any family this machine adds (scanned here, in the test); none (CI): nothing to check.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let Some(installed) = figcraft_text::family_names().into_iter().find(|f| !built_in.contains(f)) else {
|
||||
eprintln!("skipped: no installed fonts beyond the built-in ones");
|
||||
return;
|
||||
};
|
||||
assert!(families(&[], false).contains(&installed), "headless runs list {installed}");
|
||||
let face = json!({"command": "text.fonts", "params": {"family": installed, "axes": true}});
|
||||
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
|
||||
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ figcraft-collab = { workspace = true }
|
|||
figcraft-collab-net = { workspace = true }
|
||||
# Checking a team library before keeping it (read as files using it read it).
|
||||
figcraft-format = { workspace = true }
|
||||
# Checking plugin bundles before the plugin directory keeps them (never run here).
|
||||
figcraft-plugin = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -31,7 +33,14 @@ env_logger = { version = "0.11", default-features = false, features = ["humantim
|
|||
# Stopping gracefully on SIGTERM/SIGINT/SIGHUP (systemd, Docker, Ctrl+C) and Ctrl+C/close on Windows.
|
||||
ctrlc = { version = "3", features = ["termination"] }
|
||||
|
||||
# Free space on the data directory's disk (statvfs, without unsafe code of our own).
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1", default-features = false, features = ["std", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Crafting hostile archives (zip bombs) in tests.
|
||||
zip = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
figcraft-engine = { workspace = true }
|
||||
figcraft-doc = { workspace = true }
|
||||
tungstenite = { version = "0.30", default-features = false, features = ["handshake"] }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
//! What one address may do how often: new connections, publishing without the key and messages
|
||||
//! (only on a server open to the public), and reports and downloads (always). Each is a token
|
||||
//! bucket per block an address counts in ([`ip_blocks`]: an IPv4 address, or an IPv6 /64 and its
|
||||
//! /56 and /48 with 2 and 4 times the rate), so a tunnel's /48 doesn't get 65,536 /64s' worth. Publishing has a budget for
|
||||
//! everyone besides ([`PUBLISHES_ALL`] times one address's). The table keeps at most
|
||||
//! [`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::{Duration, Instant};
|
||||
|
||||
use figcraft_collab_net::hub::{Block, ip_blocks};
|
||||
use figcraft_collab_net::net::Bucket;
|
||||
|
||||
/// Most addresses remembered.
|
||||
pub const MAX_ADDRESSES: usize = 10_000;
|
||||
|
||||
/// Reports one address may send an hour.
|
||||
pub const REPORTS_PER_HOUR: f64 = 10.0;
|
||||
|
||||
/// Bytes one address may download a second (team libraries, plugins), and at once: an IPv6 /48
|
||||
/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once).
|
||||
pub const DOWNLOAD_RATE: f64 = (1u64 << 20) as f64;
|
||||
pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
|
||||
|
||||
/// Everyone together may publish without the key this many times what one address may: eight
|
||||
/// 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 {
|
||||
/// Open to the public: connections, publishing and messages are limited.
|
||||
pub open: bool,
|
||||
pub connects_per_minute: u32,
|
||||
pub publishes_per_hour: u32,
|
||||
pub messages_per_second: u32,
|
||||
}
|
||||
|
||||
/// What an address does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Connect,
|
||||
Publish,
|
||||
Message,
|
||||
/// Presence (cursors, selections, viewports), cursor chat and pings: twice `messages_per_second`.
|
||||
Presence,
|
||||
Report,
|
||||
/// Downloading this many bytes.
|
||||
Download(usize),
|
||||
/// Trying a join code.
|
||||
Code,
|
||||
}
|
||||
|
||||
struct Address {
|
||||
connects: Bucket,
|
||||
publishes: Bucket,
|
||||
messages: Bucket,
|
||||
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,
|
||||
}
|
||||
|
||||
/// The buckets of the addresses seen lately.
|
||||
pub struct Abuse {
|
||||
cfg: AbuseConfig,
|
||||
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.
|
||||
fn bucket(n: f64, period: f64) -> Bucket {
|
||||
Bucket::new(n / period, n.max(1.0))
|
||||
}
|
||||
|
||||
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, 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).
|
||||
pub fn allow(&mut self, ip: IpAddr, what: Action, now: Instant) -> bool {
|
||||
if !self.cfg.open && matches!(what, Action::Connect | Action::Publish | Action::Message | Action::Presence) {
|
||||
return true;
|
||||
}
|
||||
let keys: Vec<(Block, f64)> = ip_blocks(ip).into_iter().map(|(k, times)| (k, f64::from(times))).collect();
|
||||
// Every block must have room before any is charged.
|
||||
if !keys.iter().all(|&(k, times)| self.room(k, times, what, now)) {
|
||||
return false;
|
||||
}
|
||||
if what == Action::Publish && !self.publishes.take(now) {
|
||||
return false;
|
||||
}
|
||||
for &(k, times) in &keys {
|
||||
let (b, n) = Self::pick(self.address(k, times, now), what);
|
||||
b.take_n(now, n);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The address's buckets, made when it is new (forgetting the oldest half when full).
|
||||
fn address(&mut self, ip: Block, times: f64, now: Instant) -> &mut Address {
|
||||
if !self.table.contains_key(&ip) && self.table.len() >= MAX_ADDRESSES {
|
||||
self.forget_oldest_half();
|
||||
}
|
||||
let c = self.cfg;
|
||||
let a = self.table.entry(ip).or_insert_with(|| Address {
|
||||
connects: bucket(f64::from(c.connects_per_minute) * times, 60.0),
|
||||
publishes: bucket(f64::from(c.publishes_per_hour) * times, 3600.0),
|
||||
// A burst of four seconds' worth (a big paste, a quick drag).
|
||||
messages: Bucket::new(f64::from(c.messages_per_second) * times, f64::from(c.messages_per_second) * times * 4.0),
|
||||
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;
|
||||
a
|
||||
}
|
||||
|
||||
fn pick(a: &mut Address, what: Action) -> (&mut Bucket, f64) {
|
||||
match what {
|
||||
Action::Connect => (&mut a.connects, 1.0),
|
||||
Action::Publish => (&mut a.publishes, 1.0),
|
||||
Action::Message => (&mut a.messages, 1.0),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block has `what`'s tokens now (nothing is spent).
|
||||
fn room(&mut self, ip: Block, times: f64, what: Action, now: Instant) -> bool {
|
||||
let (b, n) = Self::pick(self.address(ip, times, now), what);
|
||||
if b.take_n(now, n) {
|
||||
b.give(n);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Addresses remembered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.table.len()
|
||||
}
|
||||
|
||||
/// None remembered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.table.is_empty()
|
||||
}
|
||||
|
||||
fn forget_oldest_half(&mut self) {
|
||||
let mut used: Vec<Instant> = self.table.values().map(|a| a.used).collect();
|
||||
used.sort_unstable();
|
||||
let Some(&cut) = used.get(used.len() / 2) else { return };
|
||||
self.table.retain(|_, a| a.used > cut);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
//! The app's way of talking to a server without waiting (`Session::start_background`), end to end
|
||||
//! over loopback: browsing and installing from the plugin directory, publishing a team library,
|
||||
//! adding it and asking for its usage each return at once and finish when the answers arrive; a
|
||||
//! server that never answers doesn't hold the caller up.
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_plugin::bundle::Bundle;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::test_util::{TempDir, config, publish, pump, session};
|
||||
|
||||
const ID: &str = "org.example.tiny";
|
||||
|
||||
fn bundle() -> Vec<u8> {
|
||||
let wasm =
|
||||
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
|
||||
let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "tiny.wasm", "permissions": {"access": "read"}});
|
||||
Bundle { manifest: manifest.to_string(), wasm, assets: Vec::new() }.encode()
|
||||
}
|
||||
|
||||
/// Start `cmd` without waiting (it must not finish at once: its answer comes over the network),
|
||||
/// then pump the session until it finished; its result.
|
||||
fn background(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
let started = Instant::now();
|
||||
assert_eq!(s.start_background(cmd, params).unwrap(), None, "{cmd} waits for its answer");
|
||||
assert!(started.elapsed() < Duration::from_secs(1), "{cmd} returned at once");
|
||||
pump(&mut [&mut *s], cmd, |s| !s[0].waiting_for(cmd));
|
||||
let (done, r) = s.take_background_done().unwrap();
|
||||
assert_eq!(done, cmd);
|
||||
r.unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> Value {
|
||||
v["id"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browsing_and_installing_finish_in_the_background() {
|
||||
let dir = TempDir::new("background-plugins");
|
||||
Store::open(dir.path(), config(dir.path()).store()).unwrap().directory().publish(&bundle()).unwrap();
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let plain = format!("ws://{}/", server.addr());
|
||||
let mut s = session("Bo");
|
||||
let listed = background(&mut s, "plugin.directory.list", json!({"server": plain}));
|
||||
assert_eq!(listed["plugins"][0]["id"], ID);
|
||||
assert_eq!(s.plugins.directory.as_ref().map(|l| l.entries.len()), Some(1), "Browse shows the listing");
|
||||
let sha = listed["plugins"][0]["sha256"].clone();
|
||||
let r = background(&mut s, "plugin.directory.install", json!({"id": ID, "confirm": true, "sha256": sha, "server": plain}));
|
||||
assert_eq!(r["installed"], ID);
|
||||
assert_eq!(s.prefs.plugins.len(), 1);
|
||||
assert!(s.plugins.unsaved, "the app saves the plugin list at once");
|
||||
// Scripts still wait for their answer.
|
||||
assert_eq!(s.execute("plugin.directory.list", &json!({})).unwrap()["plugins"][0]["installed"], "1.0.0");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_library_requests_finish_in_the_background() {
|
||||
let dir = TempDir::new("background-library");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
let f = a.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})).unwrap();
|
||||
a.execute("component.create", &json!({"ids": [id(&f)]})).unwrap();
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
// Fetching the server's current library, then publishing: two answers.
|
||||
assert_eq!(background(&mut a, "library.publishToServer", json!({}))["version"], 1);
|
||||
let mut b = session("Bo");
|
||||
let added = background(&mut b, "library.add", json!({"path": p.view}));
|
||||
assert_eq!(added["components"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(b.doc().unwrap().libraries.enabled.len(), 1);
|
||||
let usage = background(&mut a, "library.usage", json!({}));
|
||||
assert!(usage["files"].is_array(), "{usage}");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_never_answers_holds_nothing_up() {
|
||||
// It never accepts: connections wait in its backlog, and no answer ever comes.
|
||||
let hole = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let server = format!("ws://{}/", hole.local_addr().unwrap());
|
||||
let mut s = session("Cy");
|
||||
let started = Instant::now();
|
||||
assert_eq!(s.start_background("plugin.directory.list", json!({"server": server})).unwrap(), None);
|
||||
assert!(started.elapsed() < Duration::from_secs(1), "the caller didn't wait");
|
||||
assert!(s.waiting_for("plugin.directory.list"));
|
||||
// Closing the window stops waiting; the answer, when the request gives up, is dropped.
|
||||
s.cancel_background("plugin.directory.list");
|
||||
assert!(!s.waiting_for("plugin.directory.list") && s.take_background_done().is_none());
|
||||
drop(hole);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
//! The command line: `figcraft-server` serves; `key`, `files` and `links` manage the data
|
||||
//! directory. When a server is running on the directory they ask it (so revoking a link
|
||||
//! The command line: `figcraft-server` serves; `key`, `files`, `links` and `plugins` manage the
|
||||
//! data directory. When a server is running on the directory they ask it (so revoking a link
|
||||
//! disconnects whoever joined with it at once); otherwise they change the directory themselves.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
|
@ -7,7 +7,7 @@ use std::net::{IpAddr, SocketAddr};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, FileRow, LinkInfo, NewLink, PluginEntry, PluginReply, PluginReq, ReportInfo};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_collab_net::{Endpoint, Invite, manage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -25,34 +25,67 @@ figcraft-server: FigCraft's collaboration server (files, share links, live sessi
|
|||
Usage:
|
||||
figcraft-server [serve] [--listen ADDR] [--data DIR] [--public-url URL] [--config FILE]
|
||||
figcraft-server key [--rotate] the address to publish to, with the server key
|
||||
figcraft-server files [list] the files kept
|
||||
figcraft-server check check the settings (after editing the --config file)
|
||||
figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first)
|
||||
figcraft-server files remove FILE remove a file and its links for good
|
||||
figcraft-server links list FILE a file's share links
|
||||
figcraft-server links create FILE --access edit|view [--label TEXT]
|
||||
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
|
||||
figcraft-server plugins [list] the plugin directory: every plugin and its versions
|
||||
figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one)
|
||||
figcraft-server reports what people reported about files
|
||||
figcraft-server block-ip IP refuse an address (an IPv6 address: its /64) and drop its connections
|
||||
figcraft-server unblock-ip IP accept an address again
|
||||
figcraft-server blocked the blocked addresses
|
||||
|
||||
Options (also keys of the --config TOML file):
|
||||
--listen ADDR where to listen (default 127.0.0.1:47900; 0.0.0.0:47900 for every network)
|
||||
--data DIR the data directory (default ./figcraft-data)
|
||||
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
|
||||
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
|
||||
max_connections, max_people, compact_after, compact_mb
|
||||
max_connections, max_people, compact_after, compact_mb, max_plugins,
|
||||
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
|
||||
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
|
||||
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
|
||||
connects_per_minute, publishes_per_hour, messages_per_second
|
||||
-h, --help this text
|
||||
-V, --version the version
|
||||
|
||||
Logs go to standard output (RUST_LOG=debug for more); they never contain keys or tokens.
|
||||
Logs go to standard output (RUST_LOG=debug for more); they never contain keys, tokens or file contents.
|
||||
GET /healthz on the same port answers \"ok\" (for uptime checks).
|
||||
";
|
||||
|
||||
/// How `files list` orders the files.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Sort {
|
||||
/// By id.
|
||||
#[default]
|
||||
Id,
|
||||
/// Biggest first.
|
||||
Size,
|
||||
/// Oldest first.
|
||||
Age,
|
||||
/// Longest unused first.
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// What to do.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Command {
|
||||
Serve,
|
||||
Key { rotate: bool },
|
||||
Files,
|
||||
Files { sort: Sort },
|
||||
RemoveFile { file: String },
|
||||
Links { file: String },
|
||||
CreateLink { file: String, access: Access, label: String },
|
||||
Revoke { file: String, link: String },
|
||||
Plugins,
|
||||
RemovePlugin { id: String, version: Option<String> },
|
||||
Reports,
|
||||
Check,
|
||||
Block { ip: String },
|
||||
Unblock { ip: String },
|
||||
Blocked,
|
||||
Help,
|
||||
Version,
|
||||
}
|
||||
|
|
@ -75,11 +108,15 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
|||
let mut access = None;
|
||||
let mut label = String::new();
|
||||
let mut rotate = false;
|
||||
let mut version = None;
|
||||
let mut sort = Sort::Id;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut value = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value"));
|
||||
match a.as_str() {
|
||||
"-h" | "--help" => return Ok(Cli { config, command: Command::Help }),
|
||||
// After `plugins remove ID`: the version to unpublish.
|
||||
"--version" if words.first().is_some_and(|w| w == "plugins") => version = Some(value("--version")?),
|
||||
"-V" | "--version" => return Ok(Cli { config, command: Command::Version }),
|
||||
"--config" => {
|
||||
value("--config")?;
|
||||
|
|
@ -90,6 +127,14 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
|||
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
|
||||
"--label" => label = value("--label")?,
|
||||
"--rotate" => rotate = true,
|
||||
"--sort" => {
|
||||
sort = match value("--sort")?.as_str() {
|
||||
"size" => Sort::Size,
|
||||
"age" => Sort::Age,
|
||||
"idle" => Sort::Idle,
|
||||
_ => return Err("--sort must be size, age or idle".into()),
|
||||
}
|
||||
}
|
||||
s if s.starts_with('-') => return Err(format!("unknown option {s}")),
|
||||
s => words.push(s.to_string()),
|
||||
}
|
||||
|
|
@ -98,13 +143,20 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
|||
let command = match w.as_slice() {
|
||||
[] | ["serve"] => Command::Serve,
|
||||
["key"] => Command::Key { rotate },
|
||||
["files"] | ["files", "list"] => Command::Files,
|
||||
["files"] | ["files", "list"] => Command::Files { sort },
|
||||
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
|
||||
["links", "list", file] => Command::Links { file: file.to_string() },
|
||||
["links", "create", file] => {
|
||||
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
|
||||
}
|
||||
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
|
||||
["plugins"] | ["plugins", "list"] => Command::Plugins,
|
||||
["plugins", "remove", id] => Command::RemovePlugin { id: id.to_string(), version },
|
||||
["reports"] => Command::Reports,
|
||||
["check"] => Command::Check,
|
||||
["block-ip", ip] => Command::Block { ip: ip.to_string() },
|
||||
["unblock-ip", ip] => Command::Unblock { ip: ip.to_string() },
|
||||
["blocked"] => Command::Blocked,
|
||||
_ => return Err(format!("unknown command `{}`", words.join(" "))),
|
||||
};
|
||||
config.check()?;
|
||||
|
|
@ -151,8 +203,29 @@ pub fn run(cli: &Cli) -> Result<String, String> {
|
|||
Command::Version => Ok(format!("figcraft-server {}\n", env!("CARGO_PKG_VERSION"))),
|
||||
Command::Serve => Err("serving runs in the foreground: run figcraft-server without a command".into()),
|
||||
Command::Key { rotate } => key(cfg, *rotate),
|
||||
Command::Files => match admin(cfg, Admin::Files)? {
|
||||
AdminReply::Files(files) => Ok(files_table(&files)),
|
||||
Command::Files { sort } => match admin(cfg, Admin::Listing)? {
|
||||
AdminReply::Listing(files) => Ok(files_table(files, *sort, store::now())),
|
||||
other => unexpected(other),
|
||||
},
|
||||
// `parse` checked them.
|
||||
Command::Check => {
|
||||
Ok(format!("The settings are fine{}.\n", if cfg.open_publish { ": open to the public (anyone may publish without the key)" } else { "" }))
|
||||
}
|
||||
Command::Reports => match admin(cfg, Admin::Reports)? {
|
||||
AdminReply::Reports(reports) => Ok(reports_table(&reports)),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::Block { ip } => match admin(cfg, Admin::Block { ip: ip.clone() })? {
|
||||
AdminReply::Done => Ok(format!("Blocked {ip}: its connections were dropped and new ones are refused.\n")),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::Unblock { ip } => match admin(cfg, Admin::Unblock { ip: ip.clone() })? {
|
||||
AdminReply::Done => Ok(format!("Unblocked {ip}.\n")),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::Blocked => match admin(cfg, Admin::Blocked)? {
|
||||
AdminReply::Blocked(list) if list.is_empty() => Ok("No addresses are blocked.\n".into()),
|
||||
AdminReply::Blocked(list) => Ok(list.iter().map(|ip| format!("{ip}\n")).collect()),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::RemoveFile { file } => match admin(cfg, Admin::Remove { file: file.clone() })? {
|
||||
|
|
@ -173,9 +246,59 @@ pub fn run(cli: &Cli) -> Result<String, String> {
|
|||
AdminReply::Done => Ok(format!("Revoked link {link}: it no longer joins, and whoever was in the file with it was disconnected.\n")),
|
||||
other => unexpected(other),
|
||||
},
|
||||
Command::Plugins => match directory(cfg, PluginReq::List { query: String::new() })? {
|
||||
PluginReply::List(entries) => Ok(plugins_table(&entries)),
|
||||
other => unexpected_plugins(other),
|
||||
},
|
||||
Command::RemovePlugin { id, version } => match directory(cfg, PluginReq::Unpublish { id: id.clone(), version: version.clone() })? {
|
||||
PluginReply::Removed(versions) => Ok(format!("Unpublished {id} {}.\n", versions.join(", "))),
|
||||
other => unexpected_plugins(other),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn unexpected_plugins(reply: PluginReply) -> Result<String, String> {
|
||||
match reply {
|
||||
PluginReply::Failed { reason } => Err(reason),
|
||||
_ => Err("the server answered something else".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Do one plugin directory request with the server key: through the running server, or on the
|
||||
/// data directory.
|
||||
fn directory(cfg: &Config, req: PluginReq) -> Result<PluginReply, String> {
|
||||
match Store::try_open(&cfg.data, cfg.store())? {
|
||||
Some(mut store) => Ok(store.directory().handle(true, req)),
|
||||
None => {
|
||||
let addr = running(&cfg.data)?;
|
||||
let key = std::fs::read_to_string(cfg.data.join(KEY)).map_err(|e| format!("can't read the server key: {e}"))?;
|
||||
manage::plugins(&Endpoint::plain(addr.to_string()), key.trim(), req, &cfg.net(), Duration::from_secs(5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_table(entries: &[PluginEntry]) -> String {
|
||||
if entries.is_empty() {
|
||||
return "No plugins yet (publish one with `figcraft-cli plugin publish FOLDER --server ws://host:port/?key=KEY`).\n".into();
|
||||
}
|
||||
let mut out =
|
||||
String::from("ID VERSIONS NAME AUTHOR ACCESS SIZE SHA-256\n");
|
||||
for e in entries {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<32} {:<18} {:<20} {:<16} {:<6} {:<9} {}",
|
||||
elide(&e.id, 32),
|
||||
elide(&e.versions.join(","), 18),
|
||||
elide(&e.name, 20),
|
||||
elide(&e.author, 16),
|
||||
e.access,
|
||||
size(e.bytes),
|
||||
e.sha256.get(..16).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn unexpected(reply: AdminReply) -> Result<String, String> {
|
||||
match reply {
|
||||
AdminReply::Failed { reason } => Err(reason),
|
||||
|
|
@ -221,9 +344,12 @@ 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 {
|
||||
let _ = writeln!(out, "Open to the public: anyone can publish to {} without the key (each file gets an owner token).", public.url());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -242,17 +368,60 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
fn files_table(files: &[FileInfo]) -> String {
|
||||
/// When a listed file was last used (made, changed or opened).
|
||||
fn used(f: &FileRow) -> u64 {
|
||||
f.info.created.max(f.info.updated).max(f.opened)
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
let mut out = String::from("ID NAME PEOPLE UPDATED SIZE\n");
|
||||
for f in files {
|
||||
let _ = writeln!(out, "{:<16} {:<30} {:>6} {:<20} {}", f.id, elide(&f.name, 30), f.people, date(f.updated), size(f.bytes));
|
||||
match sort {
|
||||
Sort::Id => {}
|
||||
Sort::Size => files.sort_by_key(|f| std::cmp::Reverse(f.info.bytes)),
|
||||
Sort::Age => files.sort_by_key(|f| f.info.created),
|
||||
Sort::Idle => files.sort_by_key(used),
|
||||
}
|
||||
let mut out = String::from("ID NAME PEOPLE SIZE CREATED IDLE EXPIRES REPORTS BY\n");
|
||||
for f in &files {
|
||||
let idle = format!("{}d", now.saturating_sub(used(f)) / 86_400);
|
||||
let expires = f.expires.map_or_else(|| "never".to_string(), day);
|
||||
let by = if f.open { f.ip.as_str() } else { "key" };
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<16} {:<30} {:>6} {:<9} {:<10} {:>4} {:<10} {:>7} {}",
|
||||
f.info.id,
|
||||
elide(&f.info.name, 30),
|
||||
f.info.people,
|
||||
size(f.info.bytes),
|
||||
day(f.info.created),
|
||||
idle,
|
||||
expires,
|
||||
f.reports,
|
||||
by
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reports_table(reports: &[ReportInfo]) -> String {
|
||||
if reports.is_empty() {
|
||||
return "No reports.\n".into();
|
||||
}
|
||||
let mut out = String::new();
|
||||
for r in reports {
|
||||
let _ = writeln!(out, "{} file {} ({}) from {} link {}", date(r.at), r.file, elide(&r.name, 40), r.ip, r.link);
|
||||
let _ = writeln!(out, " {}", r.reason);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` for seconds since 1970.
|
||||
fn day(secs: u64) -> String {
|
||||
date(secs).chars().take(10).collect()
|
||||
}
|
||||
|
||||
fn links_table(links: &[LinkInfo]) -> String {
|
||||
if links.is_empty() {
|
||||
return "No links (make one with `figcraft-server links create FILE --access view`).\n".into();
|
||||
|
|
@ -277,18 +446,4 @@ fn size(bytes: u64) -> String {
|
|||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970.
|
||||
pub fn date(secs: u64) -> String {
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
||||
}
|
||||
pub use figcraft_collab::message::utc_date as date;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use figcraft_collab::Replica;
|
|||
use figcraft_collab::message::Access;
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use crate::cli::{self, Cli, Command, date, parse};
|
||||
use crate::cli::{self, Cli, Command, Sort, date, parse};
|
||||
use crate::config::Config;
|
||||
use crate::store::Store;
|
||||
use crate::test_util::{TempDir, config};
|
||||
|
|
@ -36,7 +36,7 @@ fn parsing() {
|
|||
parse(&args(&["links", "create", "f1", "--access", "view", "--label", "Client"])).unwrap().command,
|
||||
Command::CreateLink { file: "f1".into(), access: Access::View, label: "Client".into() }
|
||||
);
|
||||
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files);
|
||||
assert_eq!(parse(&args(&["files"])).unwrap().command, Command::Files { sort: Sort::Id });
|
||||
assert_eq!(parse(&args(&["key", "--rotate"])).unwrap().command, Command::Key { rotate: true });
|
||||
assert_eq!(parse(&args(&["-h"])).unwrap().command, Command::Help);
|
||||
for bad in
|
||||
|
|
@ -60,7 +60,7 @@ fn parsing() {
|
|||
fn managing_a_directory_on_its_own() {
|
||||
let dir = TempDir::new("cli-offline");
|
||||
let file = with_file(dir.path());
|
||||
let list = run(dir.path(), Command::Files).unwrap();
|
||||
let list = run(dir.path(), Command::Files { sort: Sort::Id }).unwrap();
|
||||
assert!(list.contains(&file) && list.contains("Board"), "{list}");
|
||||
let made = run(dir.path(), Command::CreateLink { file: file.clone(), access: Access::View, label: "Client".into() }).unwrap();
|
||||
assert!(made.contains("New view link") && made.contains(&format!("file={file}&token=")), "{made}");
|
||||
|
|
@ -75,7 +75,7 @@ fn managing_a_directory_on_its_own() {
|
|||
let rotated = run(dir.path(), Command::Key { rotate: true }).unwrap();
|
||||
assert_ne!(key.lines().nth(1), rotated.lines().nth(2));
|
||||
run(dir.path(), Command::RemoveFile { file: file.clone() }).unwrap();
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("No files yet"));
|
||||
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("No files yet"));
|
||||
assert!(run(dir.path(), Command::Links { file }).is_err());
|
||||
}
|
||||
|
||||
|
|
@ -91,12 +91,12 @@ fn managing_through_the_running_server() {
|
|||
assert!(links.contains("Team"), "{links}");
|
||||
let id = links.lines().find(|l| l.contains("Team")).and_then(|l| l.split_whitespace().next()).unwrap().to_string();
|
||||
assert!(run(dir.path(), Command::Revoke { file: file.clone(), link: id }).unwrap().contains("Revoked"));
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
|
||||
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("Board"));
|
||||
assert!(run(dir.path(), Command::Key { rotate: true }).unwrap_err().contains("stop the server"));
|
||||
assert!(run(dir.path(), Command::Key { rotate: false }).unwrap().contains(&format!("ws://{}/?key=", server.addr())));
|
||||
server.stop();
|
||||
// Stopped: the directory is free again.
|
||||
assert!(run(dir.path(), Command::Files).unwrap().contains("Board"));
|
||||
assert!(run(dir.path(), Command::Files { sort: Sort::Id }).unwrap().contains("Board"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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,404 @@
|
|||
//! Join codes on the server: sharing online from one app and joining by code from another (real
|
||||
//! links over loopback; stopping revokes the others' links and codes and disconnects them while
|
||||
//! the owner stays in the file, and sharing again makes new ones for the same file), 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");
|
||||
// Others get links of their own (the owner keeps the file's first ones): four in all.
|
||||
let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string();
|
||||
assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true);
|
||||
let ids = |dir: &std::path::Path| -> Vec<String> {
|
||||
let links: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("files").join(&p.file).join("links.json")).unwrap()).unwrap();
|
||||
links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect()
|
||||
};
|
||||
let before_stop = ids(dir.path());
|
||||
assert_eq!(before_stop.len(), 4);
|
||||
// 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"], edit_link.as_str(), "joined as with the shared 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 others are out at once and told why; the file and its owner stay.
|
||||
assert_eq!(ex(&mut a, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
|
||||
pump(&mut [&mut a, &mut b, &mut c], "the others out", |s| {
|
||||
!s[1].live().status.active() && !s[2].live().status.active() && s[0].live().peers.is_empty()
|
||||
});
|
||||
for guest in [&b, &c] {
|
||||
assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message);
|
||||
assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries");
|
||||
}
|
||||
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
|
||||
let folder = dir.path().join("files").join(&p.file);
|
||||
assert!(folder.exists() && live(&a), "the file stays on the server and Ada stays in it");
|
||||
let stopped = a.live_summary();
|
||||
assert_eq!((&stopped["share"]["shared"], &stopped["code"], &stopped["published"]["file"]), (&json!(false), &Value::Null, &json!(p.file)));
|
||||
assert_eq!(ids(dir.path()), before_stop[..2], "the owner's links are left, the shared ones are gone");
|
||||
// Neither the codes nor the links get anyone in any more.
|
||||
let mut e = app("Ed", &url);
|
||||
for old in [&edit, &view] {
|
||||
ex(&mut e, "collab.join", json!({"code": old}));
|
||||
pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some());
|
||||
assert_eq!(failed(&e).unwrap(), CODE_GONE);
|
||||
}
|
||||
ex(&mut e, "collab.join", json!({"link": edit_link}));
|
||||
pump(&mut [&mut e], "the stopped link", |s| failed(s[0]).is_some_and(|why| why.contains("isn't valid")));
|
||||
// The owner still edits the server's file.
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 200, "y": 10, "width": 40, "height": 30}));
|
||||
pump(&mut [&mut a], "the owner's edit confirmed", |s| layers(s[0]) == before + 3 && s[0].live().client().is_some_and(|c| c.pending() == 0));
|
||||
// Sharing again: new codes and links for the same file (nothing is published twice).
|
||||
let again = ex(&mut a, "collab.share", json!({}));
|
||||
let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string());
|
||||
assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}");
|
||||
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server");
|
||||
assert_eq!(ids(dir.path()).len(), 4);
|
||||
ex(&mut b, "collab.join", json!({"code": edit2}));
|
||||
pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1);
|
||||
// The old code is still gone.
|
||||
ex(&mut e, "collab.join", json!({"code": edit}));
|
||||
pump(&mut [&mut e], "the old code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyed_share_gets_codes_and_stops_with_the_key() {
|
||||
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");
|
||||
let mut b = app("Bo", &url);
|
||||
ex(&mut b, "collab.join", json!({"code": shown}));
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
|
||||
// No owner token: the key the address carried stops the sharing (the file is the operator's).
|
||||
assert_eq!(ex(&mut a, "collab.stop", json!({}))["shared"], false);
|
||||
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active());
|
||||
assert!(live(&a) && !a.live_summary().to_string().contains("key="));
|
||||
ex(&mut b, "collab.join", json!({"code": shown}));
|
||||
pump(&mut [&mut b], "the stopped code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
|
||||
// Leaving keeps the file there.
|
||||
ex(&mut a, "collab.leave", json!({}));
|
||||
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1);
|
||||
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);
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
//! The server's settings: defaults, then a TOML file (`--config server.toml`), then flags.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use figcraft_collab_net::{Endpoint, NetLimits};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::store::StoreConfig;
|
||||
use crate::abuse::AbuseConfig;
|
||||
use crate::directory::DirectoryConfig;
|
||||
use crate::store::{HostingConfig, StoreConfig};
|
||||
|
||||
/// The port the server listens on unless told otherwise (the app's sessions try it first too).
|
||||
pub const DEFAULT_PORT: u16 = figcraft_collab_net::DEFAULT_PORT;
|
||||
|
|
@ -25,8 +27,8 @@ pub struct Config {
|
|||
/// Most files kept.
|
||||
pub max_files: usize,
|
||||
/// Largest file (its snapshot), in MiB (a welcome carries the whole file, and messages are at
|
||||
/// most 64 MiB).
|
||||
pub max_file_mb: u64,
|
||||
/// most 64 MiB). Unset: 64, or 16 when open to the public ([`Config::file_mb`]).
|
||||
pub max_file_mb: Option<u64>,
|
||||
/// Most share links per file.
|
||||
pub max_links: usize,
|
||||
/// Most open connections.
|
||||
|
|
@ -37,6 +39,36 @@ pub struct Config {
|
|||
pub compact_after: usize,
|
||||
/// …or once the log is this large (MiB).
|
||||
pub compact_mb: u64,
|
||||
/// Most plugin versions the plugin directory keeps (0 turns it off).
|
||||
pub max_plugins: usize,
|
||||
/// Largest plugin bundle, in MiB.
|
||||
pub max_plugin_mb: u64,
|
||||
/// Most MiB of plugin bundles kept.
|
||||
pub max_plugins_mb: u64,
|
||||
/// Anyone may publish a file without the server key (and gets an owner token for it): a
|
||||
/// server open to the public. The limits below then apply per address.
|
||||
pub open_publish: bool,
|
||||
/// How to reach the operator (an email address or a web page) and the terms of use (a web
|
||||
/// address): the app shows them where people publish.
|
||||
pub operator_contact: String,
|
||||
pub terms_url: String,
|
||||
/// The reverse proxy in front of the server (`127.0.0.1` for Caddy or nginx on the same
|
||||
/// machine): only its `X-Forwarded-For` is believed.
|
||||
pub trusted_proxy: Option<String>,
|
||||
/// Files published without the key are deleted after this many days without anyone opening
|
||||
/// them (0: kept; fractions allowed).
|
||||
pub idle_days: f64,
|
||||
/// Publishing without the key stops while the files take this many MiB in all.
|
||||
pub max_total_mb: u64,
|
||||
/// Per address, when open: most files published without the key, and their MiB.
|
||||
pub max_files_per_ip: usize,
|
||||
pub max_mb_per_ip: u64,
|
||||
/// Per address, when open: most connections at once, new connections a minute, files
|
||||
/// published an hour and messages a second.
|
||||
pub max_connections_per_ip: usize,
|
||||
pub connects_per_minute: u32,
|
||||
pub publishes_per_hour: u32,
|
||||
pub messages_per_second: u32,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
@ -46,12 +78,27 @@ impl Default for Config {
|
|||
data: PathBuf::from("figcraft-data"),
|
||||
public_url: None,
|
||||
max_files: 1000,
|
||||
max_file_mb: 64,
|
||||
max_file_mb: None,
|
||||
max_links: 100,
|
||||
max_connections: 512,
|
||||
max_people: 256,
|
||||
compact_after: 1000,
|
||||
compact_mb: 16,
|
||||
max_plugins: 500,
|
||||
max_plugin_mb: 16,
|
||||
max_plugins_mb: 1024,
|
||||
open_publish: false,
|
||||
operator_contact: String::new(),
|
||||
terms_url: String::new(),
|
||||
trusted_proxy: None,
|
||||
idle_days: 30.0,
|
||||
max_total_mb: 10_240,
|
||||
max_files_per_ip: 20,
|
||||
max_mb_per_ip: 256,
|
||||
max_connections_per_ip: 32,
|
||||
connects_per_minute: 30,
|
||||
publishes_per_hour: 10,
|
||||
messages_per_second: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,26 +125,93 @@ impl Config {
|
|||
Ok(Some(e))
|
||||
}
|
||||
|
||||
/// The largest file in MiB: as set, else 64, or 16 on a server open to the public (strangers'
|
||||
/// files stay small unless the operator says otherwise).
|
||||
pub fn file_mb(&self) -> u64 {
|
||||
self.max_file_mb.unwrap_or(if self.open_publish { 16 } else { 64 })
|
||||
}
|
||||
|
||||
/// What an operator should hear about these settings at startup (a public server without an
|
||||
/// abuse contact, or one whose clients would all count as its proxy).
|
||||
pub fn warnings(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if !self.open_publish {
|
||||
return out;
|
||||
}
|
||||
if self.operator_contact.trim().is_empty() {
|
||||
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
|
||||
}
|
||||
let behind_proxy = self.public().ok().flatten().is_some_and(|e| e.secure) || self.addr().is_ok_and(|a| a.ip().is_loopback());
|
||||
if behind_proxy && self.trusted_proxy.as_deref().is_none_or(|p| p.trim().is_empty()) {
|
||||
out.push(
|
||||
"open_publish is on without trusted_proxy: behind a reverse proxy every client counts as the proxy's address, so one person's limits stop everyone; set trusted_proxy = \"127.0.0.1\"".into(),
|
||||
);
|
||||
}
|
||||
if self.idle_days == 0.0 {
|
||||
out.push("open_publish is on with idle_days = 0: files published without the key are never deleted".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The trusted proxy's address, when set.
|
||||
pub fn proxy(&self) -> Result<Option<IpAddr>, String> {
|
||||
let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) };
|
||||
p.parse().map(Some).map_err(|_| "`trusted_proxy` must be an IP address, like 127.0.0.1".to_string())
|
||||
}
|
||||
|
||||
/// Check everything (before starting).
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
self.addr()?;
|
||||
self.public()?;
|
||||
self.proxy()?;
|
||||
if !self.idle_days.is_finite() || !(0.0..=3650.0).contains(&self.idle_days) {
|
||||
return Err("`idle_days` must be from 0 to 3650".into());
|
||||
}
|
||||
let terms = self.terms_url.trim();
|
||||
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
|
||||
return Err("`terms_url` must be a web address (https://…)".into());
|
||||
}
|
||||
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
|
||||
return Err("`operator_contact` is at most 120 characters and `terms_url` at most 500, on one line".into());
|
||||
}
|
||||
for (name, v) in [
|
||||
("max_files", self.max_files as u64),
|
||||
("max_file_mb", self.max_file_mb),
|
||||
("max_links", self.max_links as u64),
|
||||
("max_connections", self.max_connections as u64),
|
||||
("max_people", self.max_people as u64),
|
||||
("compact_after", self.compact_after as u64),
|
||||
("compact_mb", self.compact_mb),
|
||||
("max_total_mb", self.max_total_mb),
|
||||
("max_files_per_ip", self.max_files_per_ip as u64),
|
||||
("max_mb_per_ip", self.max_mb_per_ip),
|
||||
("max_connections_per_ip", self.max_connections_per_ip as u64),
|
||||
("connects_per_minute", u64::from(self.connects_per_minute)),
|
||||
("publishes_per_hour", u64::from(self.publishes_per_hour)),
|
||||
("messages_per_second", u64::from(self.messages_per_second)),
|
||||
] {
|
||||
if v == 0 {
|
||||
return Err(format!("`{name}` must be at least 1"));
|
||||
}
|
||||
}
|
||||
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
|
||||
for (name, v) in [
|
||||
("max_files", self.max_files as u64),
|
||||
("max_file_mb", self.file_mb()),
|
||||
("max_links", self.max_links as u64),
|
||||
("max_connections", self.max_connections as u64),
|
||||
("max_people", self.max_people as u64),
|
||||
("compact_after", self.compact_after as u64),
|
||||
("compact_mb", self.compact_mb),
|
||||
("max_plugin_mb", self.max_plugin_mb),
|
||||
("max_plugins_mb", self.max_plugins_mb),
|
||||
] {
|
||||
if v == 0 {
|
||||
return Err(format!("`{name}` must be at least 1"));
|
||||
}
|
||||
}
|
||||
if self.file_mb() > 4096 || self.compact_mb > 4096 {
|
||||
return Err("sizes are in MiB and at most 4096".into());
|
||||
}
|
||||
// A bundle travels in one message (at most 64 MiB) next to its entry.
|
||||
if self.max_plugin_mb > 32 {
|
||||
return Err("`max_plugin_mb` is at most 32".into());
|
||||
}
|
||||
if self.max_plugins > 10_000 || self.max_plugins_mb > 1 << 20 {
|
||||
return Err("`max_plugins` is at most 10000 and `max_plugins_mb` at most 1048576".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -105,17 +219,84 @@ impl Config {
|
|||
pub fn store(&self) -> StoreConfig {
|
||||
let mib = |n: u64| n.saturating_mul(1 << 20);
|
||||
StoreConfig {
|
||||
hosting: HostingConfig {
|
||||
open: self.open_publish,
|
||||
operator: self.operator_contact.trim().to_string(),
|
||||
terms: self.terms_url.trim().to_string(),
|
||||
// Whole seconds: `check` keeps the days finite and at most 3650.
|
||||
idle_secs: (self.idle_days * 86_400.0).round() as u64,
|
||||
max_file_mb: self.file_mb(),
|
||||
max_total_bytes: mib(self.max_total_mb),
|
||||
max_files_per_ip: self.max_files_per_ip,
|
||||
max_bytes_per_ip: mib(self.max_mb_per_ip),
|
||||
abuse: AbuseConfig {
|
||||
open: self.open_publish,
|
||||
connects_per_minute: self.connects_per_minute,
|
||||
publishes_per_hour: self.publishes_per_hour,
|
||||
messages_per_second: self.messages_per_second,
|
||||
},
|
||||
},
|
||||
max_files: self.max_files,
|
||||
max_file_bytes: mib(self.max_file_mb),
|
||||
max_file_bytes: mib(self.file_mb()),
|
||||
max_links: self.max_links,
|
||||
compact_after: self.compact_after,
|
||||
compact_bytes: mib(self.compact_mb),
|
||||
core: figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() },
|
||||
core: self.core(),
|
||||
directory: DirectoryConfig {
|
||||
max_plugins: self.max_plugins,
|
||||
max_plugin_bytes: mib(self.max_plugin_mb),
|
||||
max_total_bytes: mib(self.max_plugins_mb),
|
||||
},
|
||||
// At least 1 GiB, and room for two of the largest files (a compaction writes one beside
|
||||
// the old one).
|
||||
min_free_bytes: mib(self.file_mb().saturating_mul(2)).max(1 << 30),
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport's part.
|
||||
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
|
||||
/// and a margin, not the core's 64 MiB.
|
||||
fn open_message(&self) -> usize {
|
||||
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// The collaboration core's part: when open, messages as large as a file at most, and the
|
||||
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
|
||||
/// quarter of a file's bytes (256 KiB at least).
|
||||
fn core(&self) -> figcraft_collab::Limits {
|
||||
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
}
|
||||
let max = self.open_message().min(base.max_message_bytes);
|
||||
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
|
||||
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
|
||||
}
|
||||
|
||||
/// The transport's part. A private server keeps the defaults; open to the public, connections
|
||||
/// per address are capped, messages are as large as a file at most, the bytes queued for a
|
||||
/// client and for everyone and those waiting for the hub are bounded, one-shot requests leave
|
||||
/// an eighth of the connections to sessions and are dropped after 20 idle seconds.
|
||||
pub fn net(&self) -> NetLimits {
|
||||
NetLimits { max_connections: self.max_connections, ..NetLimits::default() }
|
||||
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
}
|
||||
let max = self.open_message().min(base.max_client_message);
|
||||
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
|
||||
// eighth of each, and its biggest consumers are dropped first.
|
||||
let (queued, reading) = (256usize << 20, 192usize << 20);
|
||||
NetLimits {
|
||||
max_per_address: self.max_connections_per_ip,
|
||||
max_client_message: max,
|
||||
max_queued_bytes: max.saturating_add(1 << 20),
|
||||
max_queued_block: queued / 8,
|
||||
max_queued_total: queued,
|
||||
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
|
||||
max_reading_total: reading,
|
||||
max_in_flight: 128 << 20,
|
||||
reserve_for_sessions: self.max_connections / 8,
|
||||
request_idle: std::time::Duration::from_secs(20),
|
||||
..base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
//! The plugin directory: plugin bundles (`figcraft_plugin::bundle`) published to the server with
|
||||
//! its key, listed, searched and fetched by anyone who can reach it, and unpublished with the key
|
||||
//! (there are no accounts: whoever has the key is the publisher, and the operator moderates).
|
||||
//!
|
||||
//! ```text
|
||||
//! <data>/plugins/index.json every version kept: manifest fields, size, SHA-256, when
|
||||
//! <data>/plugins/<sha256>.bin a bundle, named by the SHA-256 of its bytes
|
||||
//! ```
|
||||
//!
|
||||
//! A bundle is checked in full before it is kept (it decodes within the size cap, its manifest
|
||||
//! parses and asks for a plugin API this build runs, its version is newer than the one kept, its
|
||||
//! module compiles and imports only what the sandbox provides, its asset names are plain) and is
|
||||
//! never run here. Bundles are named by their hash, so no name a publisher picks reaches the file
|
||||
//! system, and a fetch reads the file back and checks its hash before sending it. Caps: bundle
|
||||
//! size, versions kept and bytes kept; fetches share a budget of bytes per second, so a crowd of
|
||||
//! downloads can't queue more than a bounded amount of data.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use figcraft_collab::message::{PluginEntry, PluginReply, PluginReq};
|
||||
use figcraft_collab_net::net::Bucket;
|
||||
use figcraft_plugin::bundle::{Bundle, compare_versions, parse_version, sha256_hex};
|
||||
use figcraft_plugin::manifest::Access;
|
||||
|
||||
use crate::disk;
|
||||
use crate::store::now;
|
||||
|
||||
pub const PLUGINS: &str = "plugins";
|
||||
pub const INDEX: &str = "index.json";
|
||||
/// Largest `index.json` read, longest search, and most bytes of entries one listing carries.
|
||||
const MAX_INDEX_FILE: u64 = 64 << 20;
|
||||
const MAX_QUERY: usize = 200;
|
||||
const MAX_LISTING: usize = 32 << 20;
|
||||
/// What fetches may send, in MiB per second on average (each costs at least one).
|
||||
const SERVE_MIB_PER_SEC: f64 = 32.0;
|
||||
/// …and at once: eight times what one block of addresses may download at once, so no block can
|
||||
/// take it all (see `abuse`).
|
||||
const SERVE_BURST_MIB: f64 = 512.0;
|
||||
const MIB: f64 = (1 << 20) as f64;
|
||||
|
||||
/// What the directory enforces.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DirectoryConfig {
|
||||
/// Most versions kept (all plugins together); 0 turns the directory off.
|
||||
pub max_plugins: usize,
|
||||
/// Largest bundle.
|
||||
pub max_plugin_bytes: u64,
|
||||
/// Most bytes of bundles kept.
|
||||
pub max_total_bytes: u64,
|
||||
}
|
||||
|
||||
/// The plugins a server keeps.
|
||||
pub struct Directory {
|
||||
dir: PathBuf,
|
||||
cfg: DirectoryConfig,
|
||||
entries: Vec<PluginEntry>,
|
||||
served: Bucket,
|
||||
/// `index.json` couldn't be read: nothing is written over it until the operator fixes it.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
/// A plugin id as manifests have them (letters, digits, `.`, `-`, `_`; 1–128).
|
||||
fn valid_id(s: &str) -> bool {
|
||||
(1..=128).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
|
||||
}
|
||||
|
||||
fn valid_sha(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
/// Newest first.
|
||||
fn by_version(a: &PluginEntry, b: &PluginEntry) -> Ordering {
|
||||
compare_versions(&b.version, &a.version).unwrap_or(Ordering::Equal)
|
||||
}
|
||||
|
||||
impl Directory {
|
||||
/// The directory under `root` (made when the first plugin is published). Entries whose bundle
|
||||
/// is missing or the wrong size are left out and reported, never deleted.
|
||||
pub fn open(root: &Path, cfg: DirectoryConfig) -> (Directory, Vec<String>) {
|
||||
let dir = root.join(PLUGINS);
|
||||
let burst = (2 * cfg.max_plugin_bytes) as f64 / MIB;
|
||||
let mut d = Directory { dir, cfg, entries: Vec::new(), served: Bucket::new(SERVE_MIB_PER_SEC, burst.max(SERVE_BURST_MIB)), broken: false };
|
||||
let mut problems = Vec::new();
|
||||
let index = d.dir.join(INDEX);
|
||||
let _ = fs::remove_file(disk::temp_of(&index));
|
||||
if !index.exists() {
|
||||
return (d, problems);
|
||||
}
|
||||
let all: Vec<PluginEntry> =
|
||||
match disk::read_capped(&index, MAX_INDEX_FILE).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())) {
|
||||
Ok(all) => all,
|
||||
Err(e) => {
|
||||
problems.push(format!("plugin directory: {INDEX} can't be read ({e}); no plugins are listed or published until it is fixed"));
|
||||
d.broken = true;
|
||||
return (d, problems);
|
||||
}
|
||||
};
|
||||
for e in all {
|
||||
let size = fs::metadata(d.file(&e.sha256)).map(|m| m.len()).ok();
|
||||
if !valid_id(&e.id) || parse_version(&e.version).is_none() || !valid_sha(&e.sha256) || size != Some(e.bytes) {
|
||||
problems.push(format!(
|
||||
"plugin directory: skipped {} {}: its bundle is missing or damaged",
|
||||
e.id.escape_debug(),
|
||||
e.version.escape_debug()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
d.entries.push(e);
|
||||
}
|
||||
(d, problems)
|
||||
}
|
||||
|
||||
fn file(&self, sha: &str) -> PathBuf {
|
||||
self.dir.join(format!("{sha}.bin"))
|
||||
}
|
||||
|
||||
/// Every version kept.
|
||||
pub fn entries(&self) -> &[PluginEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Bytes of bundles kept (a bundle published under two versions counts once).
|
||||
fn total_bytes(&self) -> u64 {
|
||||
let files: BTreeMap<&str, u64> = self.entries.iter().map(|e| (e.sha256.as_str(), e.bytes)).collect();
|
||||
files.values().sum()
|
||||
}
|
||||
|
||||
/// Answer one request; `publisher`: the connection has the server's key.
|
||||
pub fn handle(&mut self, publisher: bool, req: PluginReq) -> PluginReply {
|
||||
let failed = |reason: String| PluginReply::Failed { reason };
|
||||
if self.cfg.max_plugins == 0 {
|
||||
return failed("this server's plugin directory is turned off".into());
|
||||
}
|
||||
let needs_key = || failed("publishing and unpublishing plugins needs the server's key (ws://host:port/?key=…)".into());
|
||||
match req {
|
||||
PluginReq::List { query } => PluginReply::List(self.list(&query)),
|
||||
PluginReq::Fetch { id, version } => match self.fetch(&id, version.as_deref()) {
|
||||
Ok((entry, bundle)) => PluginReply::Bundle { entry, bundle },
|
||||
Err(e) => failed(e),
|
||||
},
|
||||
PluginReq::Publish { .. } | PluginReq::Unpublish { .. } if !publisher => needs_key(),
|
||||
PluginReq::Publish { bundle } => self.publish(&bundle).map_or_else(failed, PluginReply::Published),
|
||||
PluginReq::Unpublish { id, version } => self.unpublish(&id, version.as_deref()).map_or_else(failed, PluginReply::Removed),
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest version of each plugin matching `query` (any case, in its id, name, description
|
||||
/// or author), by name, with the versions kept.
|
||||
pub fn list(&self, query: &str) -> Vec<PluginEntry> {
|
||||
let q: String = query.trim().chars().take(MAX_QUERY).collect::<String>().to_lowercase();
|
||||
let mut by_id: BTreeMap<&str, Vec<&PluginEntry>> = BTreeMap::new();
|
||||
for e in &self.entries {
|
||||
by_id.entry(e.id.as_str()).or_default().push(e);
|
||||
}
|
||||
let mut out: Vec<PluginEntry> = by_id
|
||||
.into_values()
|
||||
.filter_map(|mut versions| {
|
||||
versions.sort_by(|a, b| by_version(a, b));
|
||||
let mut latest = (*versions.first()?).clone();
|
||||
latest.versions = versions.iter().map(|v| v.version.clone()).collect();
|
||||
Some(latest)
|
||||
})
|
||||
.filter(|e| q.is_empty() || [&e.id, &e.name, &e.description, &e.author].iter().any(|f| f.to_lowercase().contains(&q)))
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id)));
|
||||
// A listing stays well within a message.
|
||||
let mut size = 0;
|
||||
out.retain(|e| {
|
||||
size += 256 + e.name.len() + e.description.len() + e.author.len() + e.commands.iter().map(|c| c.len() + 8).sum::<usize>();
|
||||
size <= MAX_LISTING
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The size of version `version` (or the latest) of plugin `id`'s bundle, without reading it.
|
||||
pub fn size_of(&self, id: &str, version: Option<&str>) -> Option<u64> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal)))
|
||||
.min_by(|a, b| by_version(a, b))
|
||||
.map(|e| e.bytes)
|
||||
}
|
||||
|
||||
/// Version `version` (or the latest) of plugin `id`, and its bundle, read back and checked
|
||||
/// against its hash.
|
||||
pub fn fetch(&mut self, id: &str, version: Option<&str>) -> Result<(PluginEntry, Vec<u8>), String> {
|
||||
let entry = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal)))
|
||||
.min_by(|a, b| by_version(a, b))
|
||||
.cloned()
|
||||
.ok_or_else(|| match version {
|
||||
Some(v) => format!("the directory has no version {} of that plugin", v.chars().take(32).collect::<String>()),
|
||||
None => "the directory has no such plugin (it may have been unpublished)".to_string(),
|
||||
})?;
|
||||
if !self.served.take_n(Instant::now(), 1.0 + entry.bytes as f64 / MIB) {
|
||||
return Err("the server is busy sending plugins: try again in a moment".into());
|
||||
}
|
||||
let bytes = disk::read_capped(&self.file(&entry.sha256), self.cfg.max_plugin_bytes.max(entry.bytes))?;
|
||||
if sha256_hex(&bytes) != entry.sha256 {
|
||||
log::error!("plugin directory: the bundle of {} {} is damaged on disk; not sending it", entry.id, entry.version);
|
||||
return Err("that plugin's bundle is damaged on the server; tell the server's owner".into());
|
||||
}
|
||||
Ok((entry, bytes))
|
||||
}
|
||||
|
||||
/// Keep a bundle as a new version of its plugin, after checking all of it.
|
||||
// shortcut: the bundle is checked (its module compiled) on the hub thread, like team libraries;
|
||||
// move it to a worker if large plugins make other sessions wait noticeably.
|
||||
pub fn publish(&mut self, bytes: &[u8]) -> Result<PluginEntry, String> {
|
||||
let max = self.cfg.max_plugin_bytes;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(format!("the plugin is {} KiB; this server keeps plugins up to {} KiB", bytes.len() >> 10, max >> 10));
|
||||
}
|
||||
let bundle = Bundle::decode(bytes, usize::try_from(max).unwrap_or(usize::MAX))?;
|
||||
let m = bundle.check()?;
|
||||
if let Some(latest) = self.entries.iter().filter(|e| e.id == m.id).min_by(|a, b| by_version(a, b))
|
||||
&& compare_versions(&m.version, &latest.version) != Some(Ordering::Greater)
|
||||
{
|
||||
return Err(format!("version {} of {} is on the server already: publish a newer version", latest.version, m.id));
|
||||
}
|
||||
if self.entries.len() >= self.cfg.max_plugins {
|
||||
return Err(format!("the directory keeps at most {} plugin versions: unpublish some first", self.cfg.max_plugins));
|
||||
}
|
||||
let sha = sha256_hex(bytes);
|
||||
let known = self.entries.iter().any(|e| e.sha256 == sha);
|
||||
if !known && self.total_bytes() + bytes.len() as u64 > self.cfg.max_total_bytes {
|
||||
return Err(format!("the directory is full ({} MiB): unpublish some plugins first", self.cfg.max_total_bytes >> 20));
|
||||
}
|
||||
let entry = PluginEntry {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
author: m.author.clone(),
|
||||
api: m.api,
|
||||
access: match m.permissions.access {
|
||||
Access::Read => "read".into(),
|
||||
Access::Write => "write".into(),
|
||||
},
|
||||
commands: m.permissions.commands.clone(),
|
||||
widget: m.widget,
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: sha.clone(),
|
||||
published: now(),
|
||||
versions: Vec::new(),
|
||||
};
|
||||
fs::create_dir_all(&self.dir).map_err(|e| format!("couldn't keep the plugin: {e}"))?;
|
||||
let file = self.file(&sha);
|
||||
if !known {
|
||||
disk::write_atomic(&file, bytes).map_err(|e| format!("couldn't keep the plugin: {e}"))?;
|
||||
}
|
||||
self.entries.push(entry.clone());
|
||||
if let Err(e) = self.save() {
|
||||
self.entries.pop();
|
||||
return Err(format!("couldn't keep the plugin: {e}"));
|
||||
}
|
||||
log::info!("plugin directory: published {} {} ({} bytes)", entry.id, entry.version, entry.bytes);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Remove version `version` (or every version) of plugin `id`; the versions removed.
|
||||
pub fn unpublish(&mut self, id: &str, version: Option<&str>) -> Result<Vec<String>, String> {
|
||||
let gone = |e: &PluginEntry| e.id == id && version.is_none_or(|v| compare_versions(&e.version, v) == Some(Ordering::Equal));
|
||||
let (removed, kept): (Vec<PluginEntry>, Vec<PluginEntry>) = self.entries.iter().cloned().partition(gone);
|
||||
if removed.is_empty() {
|
||||
return Err("the directory has no such plugin or version".into());
|
||||
}
|
||||
let before = std::mem::replace(&mut self.entries, kept);
|
||||
if let Err(e) = self.save() {
|
||||
self.entries = before;
|
||||
return Err(format!("couldn't unpublish: {e}"));
|
||||
}
|
||||
for r in &removed {
|
||||
if !self.entries.iter().any(|e| e.sha256 == r.sha256)
|
||||
&& let Err(e) = fs::remove_file(self.file(&r.sha256))
|
||||
{
|
||||
log::warn!("plugin directory: couldn't delete the bundle of {} {}: {e}", r.id, r.version);
|
||||
}
|
||||
}
|
||||
let versions: Vec<String> = removed.into_iter().map(|e| e.version).collect();
|
||||
log::info!("plugin directory: unpublished {id} {}", versions.join(", "));
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
fn save(&self) -> Result<(), String> {
|
||||
if self.broken {
|
||||
return Err(format!("the directory's {INDEX} is damaged; tell the server's owner"));
|
||||
}
|
||||
let text = serde_json::to_vec_pretty(&self.entries).map_err(|e| e.to_string())?;
|
||||
disk::write_atomic(&self.dir.join(INDEX), &text).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
//! The plugin directory: publishing (checked in full, key only), listing and searching, fetching
|
||||
//! (hash-checked, within a budget), unpublishing and its caps in the store; then end to end over
|
||||
//! loopback: the app's commands publish a plugin folder, another session browses without the key,
|
||||
//! installs with confirmation, runs, updates and removes it; the raw protocol refuses wrong keys and
|
||||
//! stray messages; and the server's command line lists and moderates.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, OpenPlugins, PROTOCOL, PluginReply, PluginReq, ServerMsg};
|
||||
use figcraft_plugin::bundle::{Asset, Bundle, sha256_hex};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cli::{self, Cli, Command};
|
||||
use crate::directory::{DirectoryConfig, INDEX, PLUGINS};
|
||||
use crate::store::{Store, StoreConfig};
|
||||
use crate::test_util::{Raw, TempDir, config, key, session};
|
||||
|
||||
pub(crate) fn wasm() -> Vec<u8> {
|
||||
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn manifest(id: &str, version: &str) -> String {
|
||||
json!({"id": id, "name": "Tiny tool", "version": version, "author": "Ada Example", "description": "Does very little.", "main": "tiny.wasm",
|
||||
"permissions": {"access": "write", "commands": ["shape.rectangle"]}})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn bundle(id: &str, version: &str) -> Vec<u8> {
|
||||
Bundle { manifest: manifest(id, version), wasm: wasm(), assets: vec![Asset { name: "icon.png".into(), data: vec![7; 10] }] }.encode()
|
||||
}
|
||||
|
||||
fn cfg(dir: &Path, f: impl Fn(&mut DirectoryConfig)) -> StoreConfig {
|
||||
let mut c = config(dir).store();
|
||||
f(&mut c.directory);
|
||||
c
|
||||
}
|
||||
|
||||
const ID: &str = "org.example.tiny";
|
||||
|
||||
#[test]
|
||||
fn publish_list_fetch_unpublish_and_restart() {
|
||||
let dir = TempDir::new("plugins-store");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
|
||||
let d = store.directory();
|
||||
let first = d.publish(&bundle(ID, "1.0.0")).unwrap();
|
||||
assert_eq!((first.name.as_str(), first.author.as_str(), first.access.as_str()), ("Tiny tool", "Ada Example", "write"));
|
||||
assert_eq!(first.commands, ["shape.rectangle"]);
|
||||
assert_eq!(first.sha256, sha256_hex(&bundle(ID, "1.0.0")));
|
||||
assert!(d.publish(&bundle(ID, "1.0.0")).unwrap_err().contains("newer"));
|
||||
assert!(d.publish(&bundle(ID, "0.9")).unwrap_err().contains("newer"));
|
||||
d.publish(&bundle(ID, "1.10.0")).unwrap();
|
||||
d.publish(&bundle("org.example.other", "2.0.0")).unwrap();
|
||||
let all = d.list("");
|
||||
assert_eq!(all.len(), 2);
|
||||
let tiny = all.iter().find(|e| e.id == ID).unwrap();
|
||||
assert_eq!((tiny.version.as_str(), tiny.versions.clone()), ("1.10.0", vec!["1.10.0".to_string(), "1.0.0".to_string()]));
|
||||
assert_eq!(d.list("ADA EXAMPLE").len(), 2, "search covers the author, in any case");
|
||||
assert!(d.list("nothing like it").is_empty());
|
||||
let (e, bytes) = d.fetch(ID, None).unwrap();
|
||||
assert_eq!((e.version.as_str(), sha256_hex(&bytes)), ("1.10.0", e.sha256.clone()));
|
||||
assert_eq!(d.fetch(ID, Some("1.0")).unwrap().0.version, "1.0.0");
|
||||
assert!(d.fetch(ID, Some("3.0.0")).is_err() && d.fetch("org.nope", None).is_err());
|
||||
// Unpublishing one version deletes its bundle.
|
||||
let file = dir.path().join(PLUGINS).join(format!("{}.bin", first.sha256));
|
||||
assert!(file.exists());
|
||||
assert_eq!(d.unpublish(ID, Some("1.0.0")).unwrap(), ["1.0.0"]);
|
||||
assert!(!file.exists());
|
||||
assert!(d.unpublish(ID, Some("1.0.0")).is_err());
|
||||
drop(store);
|
||||
// Kept across a restart; a bundle damaged on disk is never sent, a missing one is skipped.
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
|
||||
assert_eq!(store.directory().entries().len(), 2);
|
||||
let latest = store.directory().fetch(ID, None).unwrap().0;
|
||||
let path = dir.path().join(PLUGINS).join(format!("{}.bin", latest.sha256));
|
||||
let mut damaged = std::fs::read(&path).unwrap();
|
||||
let last = damaged.len() - 1;
|
||||
damaged[last] ^= 0xFF;
|
||||
std::fs::write(&path, &damaged).unwrap();
|
||||
assert!(store.directory().fetch(ID, None).unwrap_err().contains("damaged"));
|
||||
std::fs::write(&path, b"short").unwrap();
|
||||
drop(store);
|
||||
let store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
|
||||
assert!(store.problems().iter().any(|p| p.contains(ID) && p.contains("damaged")), "{:?}", store.problems());
|
||||
drop(store);
|
||||
// A damaged index lists nothing but deletes nothing.
|
||||
std::fs::write(dir.path().join(PLUGINS).join(INDEX), b"{not json").unwrap();
|
||||
let store = Store::open(dir.path(), cfg(dir.path(), |_| {})).unwrap();
|
||||
assert!(store.problems().iter().any(|p| p.contains(INDEX)));
|
||||
let mut store = store;
|
||||
assert!(store.directory().publish(&bundle(ID, "9.0.0")).unwrap_err().contains("damaged"), "nothing is written over a damaged index");
|
||||
assert_eq!(std::fs::read(dir.path().join(PLUGINS).join(INDEX)).unwrap(), b"{not json");
|
||||
assert!(dir.path().join(PLUGINS).join(format!("{}.bin", latest.sha256)).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_bundles_caps_and_keys() {
|
||||
let dir = TempDir::new("plugins-bad");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugin_bytes = 64 << 10)).unwrap();
|
||||
let d = store.directory();
|
||||
let with = |f: &dyn Fn(&mut Bundle)| {
|
||||
let mut b = Bundle::decode(&bundle(ID, "1.0.0"), usize::MAX).unwrap();
|
||||
f(&mut b);
|
||||
b.encode()
|
||||
};
|
||||
let wasi = wat::parse_str(r#"(module (import "wasi_snapshot_preview1" "proc_exit" (func (param i32))) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
|
||||
for (bytes, why) in [
|
||||
(b"PK\x03\x04 a zip".to_vec(), "isn't"),
|
||||
(vec![0u8; 70 << 10], "keeps plugins up to"),
|
||||
(with(&|b| b.manifest = "{}".into()), "manifest"),
|
||||
(with(&|b| b.manifest = manifest(ID, "1.0.0").replace("\"tiny.wasm\"", "\"tiny.wasm\", \"api\": 99")), "API"),
|
||||
(with(&|b| b.manifest = manifest(ID, "latest")), "version"),
|
||||
(with(&|b| b.wasm = b"not wasm".to_vec()), "WebAssembly"),
|
||||
(with(&|b| b.wasm = wasi.clone()), "imports"),
|
||||
(with(&|b| b.assets[0].name = "../../server.key".into()), "asset"),
|
||||
(with(&|b| b.assets[0].name = "aux.png".into()), "asset"),
|
||||
(with(&|b| b.assets[0].data = vec![0; 70 << 10]), "keeps plugins up to"),
|
||||
] {
|
||||
let e = d.publish(&bytes).unwrap_err();
|
||||
assert!(e.contains(why), "{why}: {e}");
|
||||
}
|
||||
assert!(d.entries().is_empty());
|
||||
// Only with the key.
|
||||
assert!(matches!(d.handle(false, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Failed { reason } if reason.contains("key")));
|
||||
assert!(matches!(d.handle(false, PluginReq::Unpublish { id: ID.into(), version: None }), PluginReply::Failed { .. }));
|
||||
assert!(matches!(d.handle(true, PluginReq::Publish { bundle: bundle(ID, "1.0.0") }), PluginReply::Published(_)));
|
||||
assert!(matches!(d.handle(false, PluginReq::List { query: "x".repeat(100_000) }), PluginReply::List(_)));
|
||||
// Fetches share a budget: a flood is turned away, not queued.
|
||||
let busy = (0..800).filter_map(|_| d.fetch(ID, None).err()).filter(|e| e.contains("busy")).count();
|
||||
assert!(busy > 0, "a flood of fetches hits the budget");
|
||||
drop(store);
|
||||
// Caps: versions kept, bytes kept, off.
|
||||
let dir = TempDir::new("plugins-caps");
|
||||
let size = bundle(ID, "1.0.0").len() as u64;
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugins = 2)).unwrap();
|
||||
store.directory().publish(&bundle(ID, "1.0.0")).unwrap();
|
||||
store.directory().publish(&bundle(ID, "1.0.1")).unwrap();
|
||||
assert!(store.directory().publish(&bundle(ID, "1.0.2")).unwrap_err().contains("at most 2"));
|
||||
drop(store);
|
||||
let dir = TempDir::new("plugins-full");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_total_bytes = size + 10)).unwrap();
|
||||
store.directory().publish(&bundle(ID, "1.0.0")).unwrap();
|
||||
assert!(store.directory().publish(&bundle(ID, "1.0.1")).unwrap_err().contains("full"));
|
||||
drop(store);
|
||||
let dir = TempDir::new("plugins-off");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path(), |d| d.max_plugins = 0)).unwrap();
|
||||
assert!(
|
||||
matches!(store.directory().handle(true, PluginReq::List { query: String::new() }), PluginReply::Failed { reason } if reason.contains("off"))
|
||||
);
|
||||
assert!(!dir.path().join(PLUGINS).exists());
|
||||
}
|
||||
|
||||
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A plugin folder: manifest, module in a subfolder, an asset.
|
||||
fn folder(root: &Path, version: &str) -> std::path::PathBuf {
|
||||
let dir = root.join(format!("tiny-{version}"));
|
||||
std::fs::create_dir_all(dir.join("assets")).unwrap();
|
||||
std::fs::write(dir.join("figcraft-plugin.json"), manifest(ID, version)).unwrap();
|
||||
std::fs::write(dir.join("tiny.wasm"), wasm()).unwrap();
|
||||
std::fs::write(dir.join("assets").join("icon.png"), [1, 2, 3]).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_browse_install_update_and_remove_over_the_network() {
|
||||
let dir = TempDir::new("plugins-net");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let with_key = format!("ws://{addr}/?key={}", key(dir.path()));
|
||||
let plain = format!("ws://{addr}/");
|
||||
// The publisher: the server's key.
|
||||
let mut publisher = session("Ada");
|
||||
let e = publisher.execute("plugin.directory.publish", &json!({"path": folder(dir.path(), "1.0.0"), "server": plain})).unwrap_err().to_string();
|
||||
assert!(e.contains("key"), "{e}");
|
||||
let r = ex(&mut publisher, "plugin.directory.publish", json!({"path": folder(dir.path(), "1.0.0"), "server": with_key}));
|
||||
assert_eq!(r["published"]["version"], "1.0.0");
|
||||
let wrong = format!("ws://{addr}/?key=0123456789abcdef0123");
|
||||
assert!(
|
||||
publisher
|
||||
.execute("plugin.directory.publish", &json!({"path": folder(dir.path(), "1.0.1"), "server": wrong}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("wrong server key")
|
||||
);
|
||||
// A user: browses without the key, installs only with confirmation, runs it.
|
||||
let mut user = session("Bo");
|
||||
let listed = ex(&mut user, "plugin.directory.list", json!({"server": plain}));
|
||||
let p = &listed["plugins"][0];
|
||||
assert_eq!((p["id"].as_str(), p["access"].as_str(), p["installed"].as_str()), (Some(ID), Some("write"), None));
|
||||
assert!(user.execute("plugin.directory.install", &json!({"id": ID})).is_err());
|
||||
let sha = p["sha256"].as_str().unwrap().to_string();
|
||||
let r = ex(&mut user, "plugin.directory.install", json!({"id": ID, "confirm": true, "sha256": sha}));
|
||||
assert_eq!((r["version"].as_str(), r["source"].as_str()), (Some("1.0.0"), Some(plain.as_str())));
|
||||
ex(&mut user, "plugin.run", json!({"id": ID}));
|
||||
// An update appears and installs over it; browsing users can't unpublish.
|
||||
ex(&mut publisher, "plugin.directory.publish", json!({"path": folder(dir.path(), "1.0.1"), "server": with_key}));
|
||||
let listed = ex(&mut user, "plugin.directory.list", json!({}));
|
||||
assert_eq!((listed["plugins"][0]["version"].as_str(), listed["plugins"][0]["update"].as_bool()), (Some("1.0.1"), Some(true)));
|
||||
let r = ex(&mut user, "plugin.directory.install", json!({"id": ID, "confirm": true}));
|
||||
assert_eq!((r["version"].as_str(), r["replaced"].as_bool()), (Some("1.0.1"), Some(true)));
|
||||
assert!(user.execute("plugin.directory.unpublish", &json!({"id": ID, "server": plain})).unwrap_err().to_string().contains("key"));
|
||||
ex(&mut user, "plugin.remove", json!({"id": ID}));
|
||||
let r = ex(&mut publisher, "plugin.directory.unpublish", json!({"id": ID, "server": with_key}));
|
||||
assert_eq!(r["removed"], json!(["1.0.0", "1.0.1"]));
|
||||
assert!(ex(&mut user, "plugin.directory.list", json!({}))["plugins"].as_array().unwrap().is_empty());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_raw_protocol_refuses_wrong_keys_and_stray_messages() {
|
||||
let dir = TempDir::new("plugins-raw");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let open = |key: &str| ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: key.into(), client_version: String::new() });
|
||||
let answer = |raw: &mut Raw| match raw.next() {
|
||||
ServerMsg::Plugins(r) => *r,
|
||||
other => panic!("not a directory answer: {other:?}"),
|
||||
};
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open("not the key"));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("wrong server key")));
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL + 1, key: String::new(), client_version: String::new() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("protocol")));
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(""));
|
||||
assert_eq!(answer(&mut raw), PluginReply::Ready { publisher: false });
|
||||
raw.send(&ClientMsg::Plugins(PluginReq::Publish { bundle: bundle(ID, "1.0.0") }));
|
||||
assert!(matches!(answer(&mut raw), PluginReply::Failed { reason } if reason.contains("key")));
|
||||
raw.send(&ClientMsg::Plugins(PluginReq::Fetch { id: "../../server.key".into(), version: Some("..".into()) }));
|
||||
assert!(matches!(answer(&mut raw), PluginReply::Failed { .. }));
|
||||
raw.send(&ClientMsg::Ping);
|
||||
assert_eq!(raw.next(), ServerMsg::Pong);
|
||||
raw.send(&ClientMsg::Admin(figcraft_collab::message::Admin::Files));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("plugin directory")));
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&key(dir.path())));
|
||||
assert_eq!(answer(&mut raw), PluginReply::Ready { publisher: true });
|
||||
raw.send(&ClientMsg::Plugins(PluginReq::Publish { bundle: bundle(ID, "1.0.0") }));
|
||||
assert!(matches!(answer(&mut raw), PluginReply::Published(e) if e.id == ID));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_line_lists_and_moderates() {
|
||||
let dir = TempDir::new("plugins-cli");
|
||||
let run = |command: Command| cli::run(&Cli { config: config(dir.path()), command });
|
||||
assert!(run(Command::Plugins).unwrap().contains("No plugins yet"));
|
||||
Store::open(dir.path(), config(dir.path()).store()).unwrap().directory().publish(&bundle(ID, "1.0.0")).unwrap();
|
||||
let table = run(Command::Plugins).unwrap();
|
||||
assert!(table.contains(ID) && table.contains("Ada Example") && table.contains("write"), "{table}");
|
||||
// Through the running server too.
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
assert!(run(Command::Plugins).unwrap().contains(ID));
|
||||
assert!(run(Command::RemovePlugin { id: ID.into(), version: Some("2.0.0".into()) }).is_err());
|
||||
assert!(run(Command::RemovePlugin { id: ID.into(), version: None }).unwrap().contains("Unpublished"));
|
||||
server.stop();
|
||||
assert!(run(Command::Plugins).unwrap().contains("No plugins yet"));
|
||||
let parsed = cli::parse(&["plugins".to_string(), "remove".into(), ID.into(), "--version".into(), "1.0.0".into()]).unwrap();
|
||||
assert_eq!(parsed.command, Command::RemovePlugin { id: ID.into(), version: Some("1.0.0".into()) });
|
||||
assert_eq!(cli::parse(&["--version".to_string()]).unwrap().command, Command::Version);
|
||||
}
|
||||
|
|
@ -32,6 +32,19 @@ const SNAP_HEADER: usize = 4 + 1 + 8 + 4;
|
|||
pub const LOG_HEADER: usize = 4 + 1 + 8;
|
||||
const RECORD_HEADER: usize = 8;
|
||||
|
||||
/// Free space on the disk holding `path`, in bytes, where it can be told (Unix).
|
||||
pub fn free_bytes(path: &Path) -> Option<u64> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
rustix::fs::statvfs(path).ok().map(|s| s.f_bavail.saturating_mul(s.f_frsize))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with `.tmp` added: where its next version is written first.
|
||||
pub fn temp_of(path: &Path) -> PathBuf {
|
||||
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
|
||||
|
|
@ -41,9 +54,27 @@ pub fn temp_of(path: &Path) -> PathBuf {
|
|||
|
||||
/// Replace `path` with `bytes` atomically: a reader sees the old or the new content, never a mix.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_as(path, bytes, false)
|
||||
}
|
||||
|
||||
/// [`write_atomic`] for a secret: on Unix only the owner can read it, from the moment it exists.
|
||||
pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_as(path, bytes, true)
|
||||
}
|
||||
|
||||
fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> {
|
||||
let tmp = temp_of(path);
|
||||
{
|
||||
let mut f = File::create(&tmp)?;
|
||||
let mut o = fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
if private {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
let mut f = o.open(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,802 @@
|
|||
//! Open hosting against resource exhaustion: each test reproduces an attack from the security
|
||||
//! review and checks what bounds it — a zip-bomb library (H1), a client that never reads its
|
||||
//! replies (H2), a file growing past its cap through batches and a log that can't be written (H3),
|
||||
//! usage reports that rewrote a file per request (M1), oversized messages and a busy hub (M2),
|
||||
//! many addresses from one IPv6 block and one-shot requests taking every connection (M3), revoked
|
||||
//! links on library connections (M4), data directory permissions (L6) and downloads per address
|
||||
//! (L7).
|
||||
|
||||
use std::io::{Cursor, Write};
|
||||
use std::net::{IpAddr, TcpListener};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{
|
||||
Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReq, ServerMsg, Usage,
|
||||
};
|
||||
use figcraft_collab::sim::Rng;
|
||||
use figcraft_collab::{Client, Limits, NoRecompute, Outgoing, Replica, Server};
|
||||
use figcraft_collab_net::hub::{Backend, Hosting};
|
||||
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST};
|
||||
use crate::config::Config;
|
||||
use crate::public_tests::via_proxy;
|
||||
use crate::store::{BUSY_DOWNLOADS, NO_PUBLIC_LIBRARY, Store};
|
||||
use crate::test_util::{Raw, TempDir, config};
|
||||
|
||||
fn open(dir: &std::path::Path) -> Config {
|
||||
Config { open_publish: true, operator_contact: "abuse@example.com".into(), ..config(dir) }
|
||||
}
|
||||
|
||||
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
|
||||
let mut d = Document::new(name);
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
|
||||
d.repair();
|
||||
Replica::new(d).snapshot().unwrap()
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
/// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot
|
||||
/// (deflated: a few KiB) and `padding` incompressible bytes stored beside it.
|
||||
fn archive(bomb: usize, padding: usize) -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("component.create", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let lib = s.doc().unwrap().published.clone().unwrap();
|
||||
let bytes = figcraft_engine::library_server::archive(&lib).unwrap();
|
||||
if bomb == 0 && padding == 0 {
|
||||
return bytes;
|
||||
}
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..src.len() {
|
||||
let f = src.by_index_raw(i).unwrap();
|
||||
if bomb > 0 && f.name() == "library/document.json" {
|
||||
continue;
|
||||
}
|
||||
out.raw_copy_file(f).unwrap();
|
||||
}
|
||||
let opts = |m| zip::write::SimpleFileOptions::default().compression_method(m);
|
||||
if bomb > 0 {
|
||||
out.start_file("library/document.json", opts(zip::CompressionMethod::Deflated)).unwrap();
|
||||
let chunk = vec![b' '; 1 << 20];
|
||||
for _ in 0..bomb >> 20 {
|
||||
out.write_all(&chunk).unwrap();
|
||||
}
|
||||
}
|
||||
if padding > 0 {
|
||||
out.start_file("padding.bin", opts(zip::CompressionMethod::Stored)).unwrap();
|
||||
let mut rng = Rng::new(3);
|
||||
out.write_all(&(0..padding).map(|_| rng.next_u64() as u8).collect::<Vec<u8>>()).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn open_library(link: &Invite) -> ClientMsg {
|
||||
ClientMsg::OpenLibrary(OpenLibrary { protocol: PROTOCOL, file: link.file.clone(), token: link.token.clone(), client_version: String::new() })
|
||||
}
|
||||
|
||||
fn hosting() -> ClientMsg {
|
||||
ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() })
|
||||
}
|
||||
|
||||
/// Poll `f` until it holds or `secs` pass (bounded: never a fixed sleep for the outcome).
|
||||
fn until(secs: u64, what: &str, mut f: impl FnMut() -> bool) {
|
||||
let end = Instant::now() + Duration::from_secs(secs);
|
||||
while !f() {
|
||||
assert!(Instant::now() < end, "timed out waiting for {what}");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// A client in `server` (as the hub would let it in).
|
||||
fn join(server: &mut Server) -> Client {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
|
||||
let (_, out) = server.join_as(h, Access::Edit).unwrap();
|
||||
let Some(Outgoing::To(_, w)) = out.into_iter().next() else { panic!() };
|
||||
Client::join(w).unwrap()
|
||||
}
|
||||
|
||||
/// Rename the first page through `c`, as the server takes it.
|
||||
fn rename(server: &mut Server, c: &mut Client, name: &str) {
|
||||
let mut d = c.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().name = name.into();
|
||||
let m = c.edit(&d).unwrap().unwrap();
|
||||
for o in server.receive(c.id(), m) {
|
||||
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
|
||||
c.receive(m, &NoRecompute).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// H1 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_zip_bomb_library_is_refused_before_it_unpacks() {
|
||||
let dir = TempDir::new("bomb");
|
||||
let mut c = config(dir.path());
|
||||
c.max_file_mb = Some(1);
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (keyed, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
let bomb = archive(64 << 20, 0);
|
||||
assert!(bomb.len() < 1 << 20, "a small archive ({} bytes) that unpacks to 64 MiB", bomb.len());
|
||||
let e = store.publish_library(&keyed.id, &bomb).unwrap_err();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert_eq!(store.publish_library(&keyed.id, &archive(0, 0)).unwrap(), 1, "a real library still goes in");
|
||||
drop(store);
|
||||
// Open to the public: a file published without the key holds no library at all.
|
||||
let mut store = Store::open(dir.path(), open(dir.path()).store()).unwrap();
|
||||
let (mine, ..) = store.publish_open(ip("203.0.113.5"), "Mine", &snapshot("Mine")).unwrap();
|
||||
assert_eq!(store.publish_library(&mine.id, &archive(0, 0)).unwrap_err(), NO_PUBLIC_LIBRARY);
|
||||
assert_ne!(store.publish_library(&keyed.id, &archive(0, 0)).unwrap_err(), NO_PUBLIC_LIBRARY, "the operator's files still can");
|
||||
}
|
||||
|
||||
// H2 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_client_that_never_reads_can_queue_only_so_much() {
|
||||
let dir = TempDir::new("noread");
|
||||
let cfg = Config { max_file_mb: Some(2), max_plugin_mb: 1, ..open(dir.path()) };
|
||||
let cap = cfg.net().max_queued_bytes;
|
||||
assert_eq!(cap, 4 << 20, "the largest message and a margin");
|
||||
let view = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 1_500_000)).unwrap();
|
||||
Invite { server: Endpoint::plain("x"), file: file.id, token: view.token }
|
||||
};
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&open_library(&view));
|
||||
assert!(matches!(raw.next(), ServerMsg::Library(r) if matches!(*r, LibraryReply::Ready { .. })));
|
||||
// Ask for the 1.5 MB library over and over without reading an answer.
|
||||
for _ in 0..100 {
|
||||
if raw.try_send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None })).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut most = 0;
|
||||
let end = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < end {
|
||||
most = most.max(server.queued_bytes());
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(most <= cap, "{most} bytes were queued for one client (cap {cap})");
|
||||
// Its queue is let go once it is dropped.
|
||||
until(15, "the stuck client's queue to be freed", || server.queued_bytes() == 0);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// H3 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_file_cant_grow_past_its_cap_through_batches() {
|
||||
let dir = TempDir::new("grow");
|
||||
let cfg = Config { max_file_mb: Some(1), max_plugin_mb: 1, ..open(dir.path()) };
|
||||
let max = 1u64 << 20;
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let (info, replies) = manage::hosting(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
"",
|
||||
vec![Admin::Publish { name: "Grow".into(), snapshot: snapshot("Grow") }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(info.open);
|
||||
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
|
||||
let welcome = loop {
|
||||
if let w @ ServerMsg::Welcome { .. } = raw.next() {
|
||||
break w;
|
||||
}
|
||||
};
|
||||
let mut client = Client::join(welcome).unwrap();
|
||||
// Each batch adds a layer with a 200 KB name: the file grows until the server refuses.
|
||||
let mut refused = None;
|
||||
for i in 0..12 {
|
||||
let mut d = client.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, &format!("{i}{}", "x".repeat(200_000)), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let Some(m) = client.edit(&d).unwrap() else { continue };
|
||||
raw.send(&m);
|
||||
let answer = loop {
|
||||
if let m @ (ServerMsg::Changes(_) | ServerMsg::Reject { .. }) = raw.next() {
|
||||
break m;
|
||||
}
|
||||
};
|
||||
if let ServerMsg::Reject { reason, .. } = &answer {
|
||||
refused = Some(reason.clone());
|
||||
}
|
||||
client.receive(answer, &NoRecompute).unwrap();
|
||||
if refused.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let reason = refused.expect("the server refused a batch once the file was full");
|
||||
assert!(reason.contains("reached the server's limit of 1 MiB"), "{reason}");
|
||||
let on_disk: u64 = ["snapshot.bin", "log.bin"]
|
||||
.iter()
|
||||
.filter_map(|n| std::fs::metadata(dir.path().join("files").join(&file.id).join(n)).ok())
|
||||
.map(|m| m.len())
|
||||
.sum();
|
||||
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
|
||||
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
|
||||
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
|
||||
drop(raw);
|
||||
server.stop();
|
||||
// Everything confirmed opens again with the same settings, after compacting on the way out.
|
||||
let kept = client.document().nodes().count();
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
|
||||
let welcome = loop {
|
||||
match raw.next() {
|
||||
w @ ServerMsg::Welcome { .. } => break w,
|
||||
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sessions_memory_of_batches_is_bounded_in_bytes() {
|
||||
let mut server = Server::new(Document::new("Log"), "", Limits { max_log_bytes: 100_000, ..Limits::default() });
|
||||
let mut c = join(&mut server);
|
||||
for i in 0..20 {
|
||||
rename(&mut server, &mut c, &format!("{i}{}", "y".repeat(30_000)));
|
||||
assert!(server.log_bytes() <= 100_000, "{} bytes kept", server.log_bytes());
|
||||
}
|
||||
assert!(server.log_bytes() > 0, "recent batches are still kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_that_cant_be_saved_are_never_confirmed() {
|
||||
let dir = TempDir::new("unsaved");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (info, ..) = store.publish("Durable", &snapshot("Durable")).unwrap();
|
||||
let mut server = Backend::open(&mut store, &info.id).unwrap();
|
||||
let mut c = join(&mut server);
|
||||
rename(&mut server, &mut c, "Kept");
|
||||
Backend::changed(&mut store, &info.id, &mut server);
|
||||
assert!(Backend::flush(&mut store).is_empty());
|
||||
rename(&mut server, &mut c, "Lost");
|
||||
Backend::changed(&mut store, &info.id, &mut server);
|
||||
store.break_log(&info.id);
|
||||
assert_eq!(Backend::flush(&mut store), vec![info.id.clone()], "the hub hears it couldn't be saved");
|
||||
// The file opens again from what is on disk: the saved change, not the unsaved one.
|
||||
drop(server);
|
||||
let mut server = Backend::open(&mut store, &info.id).unwrap();
|
||||
let c = join(&mut server);
|
||||
let page = c.document().first_page().unwrap();
|
||||
assert_eq!(c.document().node(page).unwrap().name, "Kept");
|
||||
}
|
||||
|
||||
// M1 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn usage_reports_dont_rewrite_a_file_per_request() {
|
||||
let dir = TempDir::new("usage");
|
||||
let cfg = config(dir.path());
|
||||
let view = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 0)).unwrap();
|
||||
Invite { server: Endpoint::plain("x"), file: file.id, token: view.token }
|
||||
};
|
||||
let usage_file = dir.path().join("files").join(&view.file).join("usage.json");
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&open_library(&view));
|
||||
raw.next();
|
||||
for i in 0..50u32 {
|
||||
let usage = Usage { file: format!("User {}", i % 7), components: vec![(NodeId(2), i + 1)] };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(1), usage: Some(usage) }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Library(r) if matches!(*r, LibraryReply::Unchanged { .. })));
|
||||
}
|
||||
assert!(!usage_file.exists(), "kept in memory, not written 50 times");
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let ServerMsg::Library(r) = raw.next() else { panic!() };
|
||||
let LibraryReply::Usage(files) = *r else { panic!() };
|
||||
assert_eq!(files.len(), 7);
|
||||
server.stop();
|
||||
assert!(usage_file.exists(), "written when the server stops");
|
||||
}
|
||||
|
||||
// M2 -------------------------------------------------------------------------------------------
|
||||
|
||||
/// Panics in the server's connection threads (`live-…`) since the hook went in: they mustn't
|
||||
/// happen, but a thread's panic doesn't fail a test by itself.
|
||||
static CONNECTION_PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
fn count_connection_panics() {
|
||||
static HOOK: std::sync::Once = std::sync::Once::new();
|
||||
HOOK.call_once(|| {
|
||||
let before = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if std::thread::current().name().is_some_and(|n| n.starts_with("live-")) {
|
||||
CONNECTION_PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
before(info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_are_capped_by_what_the_connection_is_for() {
|
||||
count_connection_panics();
|
||||
let dir = TempDir::new("caps");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
// Browsing a plugin directory takes small requests only; the oversized frame is refused and
|
||||
// the connection closed cleanly (its reader isn't asked for the rest of that frame).
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: String::new(), client_version: String::new() }));
|
||||
raw.next();
|
||||
raw.send_header(200_000);
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("too large") && reason.contains("KiB")));
|
||||
drop(raw);
|
||||
// A joined client whose batch is bigger than a file may be is told the limit.
|
||||
let (_, replies) = manage::hosting(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
"",
|
||||
vec![Admin::Publish { name: "Small".into(), snapshot: snapshot("Small") }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
|
||||
let mut joined = Raw::connect(addr);
|
||||
joined.send(&Client::hello(&file.id, &edit.token, "Big", ""));
|
||||
while !matches!(joined.next(), ServerMsg::Welcome { .. }) {}
|
||||
joined.send_header(40 << 20);
|
||||
let reason = loop {
|
||||
if let ServerMsg::Refused { reason } = joined.next() {
|
||||
break reason;
|
||||
}
|
||||
};
|
||||
assert!(reason.contains("at most 17 MiB"), "{reason}");
|
||||
drop(joined);
|
||||
until(10, "the refused connections to close", || server.connections() == 0);
|
||||
assert_eq!(CONNECTION_PANICS.load(std::sync::atomic::Ordering::SeqCst), 0, "a connection thread panicked");
|
||||
// Open to the public, a message is at most a file's size (and a margin), not 64 MiB.
|
||||
assert_eq!(open(dir.path()).net().max_client_message, 17 << 20);
|
||||
server.stop();
|
||||
// Past the bytes the hub may have waiting, a connection is dropped as busy.
|
||||
let cfg = open(dir.path());
|
||||
let store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut busy = Hosting::serve(listener, store, NetLimits { max_in_flight: 10_000, ..cfg.net() }).unwrap();
|
||||
let mut raw = Raw::connect(busy.addr());
|
||||
raw.send(&hosting());
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
let mut big = Document::new("Big");
|
||||
let page = big.first_page().unwrap();
|
||||
big.create(page, NodeKind::Rectangle, &"z".repeat(30_000), 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
raw.send(&ClientMsg::Admin(Admin::Publish { name: "Big".into(), snapshot: Replica::new(big).snapshot().unwrap() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("busy")));
|
||||
busy.stop();
|
||||
}
|
||||
|
||||
// M3 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn one_ipv6_block_gets_rising_but_bounded_limits() {
|
||||
let dir = TempDir::new("blocks");
|
||||
let cfg = Config { max_connections_per_ip: 1, trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut held = Vec::new();
|
||||
// Two /64s of one /56 fit (2 × the cap), the third doesn't.
|
||||
for n in 1..=2 {
|
||||
let (ws, r) = via_proxy(addr, &format!("2001:db8:1:{n}::1"));
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)), "/64 number {n}");
|
||||
held.push(ws);
|
||||
}
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:1:3::1").1, ServerMsg::Refused { reason } if reason.contains("too many connections")));
|
||||
// Four of one /48 fit (4 × the cap) — two of them taken above — the fifth doesn't.
|
||||
for n in 1..=2u16 {
|
||||
let (ws, r) = via_proxy(addr, &format!("2001:db8:1:{:x}::1", n << 8));
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)), "/56 number {n}");
|
||||
held.push(ws);
|
||||
}
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:1:300::1").1, ServerMsg::Refused { .. }), "the /48 is full");
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:2::1").1, ServerMsg::Hosting(_)), "another /48 isn't");
|
||||
drop(held);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rates_count_per_block_and_publishing_for_everyone() {
|
||||
let mut a = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
|
||||
let t = Instant::now();
|
||||
// One connection a minute per /64, two per /56, four per /48.
|
||||
let in_one_56 = (0..64u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 7, *n, 0, 0, 0, 0]), Action::Connect, t)).count();
|
||||
assert_eq!(in_one_56, 2);
|
||||
let in_one_48 = (1..64u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 7, n << 8, 0, 0, 0, 0]), Action::Connect, t)).count();
|
||||
assert_eq!(in_one_48, 2, "the /48's four, two of them taken above");
|
||||
// Publishing: one an hour per address, 32 for everyone together.
|
||||
let published = (0..60u32).filter(|n| a.allow(IpAddr::from((0xC000_0200 + n).to_be_bytes()), Action::Publish, t)).count();
|
||||
assert_eq!(published, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_requests_leave_connections_for_sessions() {
|
||||
let dir = TempDir::new("reserve");
|
||||
let cfg = Config { max_connections: 8, ..open(dir.path()) };
|
||||
assert_eq!(cfg.net().reserve_for_sessions, 1);
|
||||
let edit = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, edit, _) = store.publish("Team", &snapshot("Team")).unwrap();
|
||||
(file.id, edit.token)
|
||||
};
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut held = Vec::new();
|
||||
for _ in 0..7 {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&hosting());
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
held.push(raw);
|
||||
}
|
||||
let mut eighth = Raw::connect(addr);
|
||||
eighth.send(&hosting());
|
||||
assert!(matches!(eighth.next(), ServerMsg::Refused { reason } if reason.contains("busy")));
|
||||
drop(eighth);
|
||||
until(5, "the refused connection to close", || server.connections() <= 7);
|
||||
let mut session = Raw::connect(addr);
|
||||
session.send(&Client::hello(&edit.0, &edit.1, "Ada", ""));
|
||||
assert!(matches!(session.next(), ServerMsg::Welcome { .. }), "a session still gets in");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// M4 -------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn revoking_or_removing_cuts_library_connections_too() {
|
||||
let dir = TempDir::new("libkick");
|
||||
let cfg = config(dir.path());
|
||||
let (file, view) = {
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let (file, _, view) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 0)).unwrap();
|
||||
(file.id, view)
|
||||
};
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let invite = Invite { server: Endpoint::plain("x"), file: file.clone(), token: view.token.clone() };
|
||||
let mut lib = Raw::connect(addr);
|
||||
lib.send(&open_library(&invite));
|
||||
lib.next();
|
||||
let key = crate::test_util::key(dir.path());
|
||||
manage::request(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
&key,
|
||||
vec![Admin::Revoke { file: file.clone(), link: view.link.id.clone() }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(lib.next(), ServerMsg::Refused { reason } if reason.contains("revoked")));
|
||||
// A library connection with another link is cut when the file is removed.
|
||||
let made = manage::request(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
&key,
|
||||
vec![Admin::CreateLink { file: file.clone(), access: Access::View, label: String::new() }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(AdminReply::Link(l)) = made.into_iter().next() else { panic!() };
|
||||
let mut lib = Raw::connect(addr);
|
||||
lib.send(&open_library(&Invite { token: l.token, ..invite }));
|
||||
lib.next();
|
||||
manage::request(&Endpoint::plain(addr.to_string()), &key, vec![Admin::Remove { file }], &NetLimits::default(), Duration::from_secs(5)).unwrap();
|
||||
assert!(matches!(lib.next(), ServerMsg::Refused { reason } if reason.contains("removed")));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
// L6, L7 ---------------------------------------------------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_the_server_reads_its_data_directory_and_key() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new("perms");
|
||||
let data = dir.path().join("data");
|
||||
let store = Store::open(&data, config(&data).store()).unwrap();
|
||||
drop(store);
|
||||
assert_eq!(std::fs::metadata(&data).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
assert_eq!(std::fs::metadata(data.join("server.key")).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_have_a_share_per_address() {
|
||||
let mut a = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
|
||||
let t = Instant::now();
|
||||
let me = ip("198.51.100.1");
|
||||
let chunk = 16 << 20;
|
||||
let served = (0..10).filter(|_| a.allow(me, Action::Download(chunk), t)).count();
|
||||
assert_eq!(served, (DOWNLOAD_BURST as usize) / chunk, "a burst, then wait");
|
||||
assert!(a.allow(ip("198.51.100.2"), Action::Download(chunk), t), "another address has its own share");
|
||||
// A team library is served within it, then refused, saying why; another address still gets it.
|
||||
let dir = TempDir::new("libshare");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 4 << 20)).unwrap();
|
||||
let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
|
||||
let served = (0..40).take_while(|_| matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Library { .. })).count();
|
||||
assert!((3..=5).contains(&served), "{served} downloads of 4 MiB in a burst of 16 MiB");
|
||||
assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Failed { reason } if reason == BUSY_DOWNLOADS));
|
||||
assert!(matches!(fetch(&mut store, "198.51.100.9"), LibraryReply::Library { .. }));
|
||||
}
|
||||
|
||||
// Second review: N2, L7, N3, M3, N5, N7 ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_file_at_its_cap_is_compacted_at_most_once_a_minute() {
|
||||
let dir = TempDir::new("forced");
|
||||
let mut c = config(dir.path());
|
||||
c.max_file_mb = Some(1);
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (info, ..) = store.publish("Full", &snapshot("Full")).unwrap();
|
||||
let mut server = Backend::open(&mut store, &info.id).unwrap();
|
||||
let mut client = join(&mut server);
|
||||
let before = store.compactions();
|
||||
// Alternate a small accepted batch with one that would take the file over its cap.
|
||||
for i in 0..50 {
|
||||
rename(&mut server, &mut client, &format!("page {i}"));
|
||||
Backend::changed(&mut store, &info.id, &mut server);
|
||||
assert!(Backend::room(&mut store, &info.id, &server, 2 << 20).is_err());
|
||||
}
|
||||
assert!(store.compactions() - before <= 1, "{} compactions for 50 over-cap batches", store.compactions() - before);
|
||||
}
|
||||
|
||||
/// A plugin directory with one plugin whose bundle is about `mib` MiB.
|
||||
fn plugin_store(dir: &std::path::Path, mib: usize) -> Store {
|
||||
let mut store = Store::open(dir, config(dir).store()).unwrap();
|
||||
let bundle = figcraft_plugin::bundle::Bundle {
|
||||
manifest: crate::directory_tests::manifest("org.example.big", "1.0.0"),
|
||||
wasm: crate::directory_tests::wasm(),
|
||||
assets: vec![figcraft_plugin::bundle::Asset { name: "data.bin".into(), data: vec![9; mib << 20] }],
|
||||
};
|
||||
store.directory().publish(&bundle.encode()).unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_address_cant_spend_everyones_plugin_downloads() {
|
||||
let dir = TempDir::new("plugshare");
|
||||
let mut store = plugin_store(dir.path(), 4);
|
||||
let fetch = || PluginReq::Fetch { id: "org.example.big".into(), version: None };
|
||||
let greedy = ip("198.51.100.1");
|
||||
// Its share runs out after a few; then 200 more tries cost nobody else anything.
|
||||
let served =
|
||||
(0..10).take_while(|_| matches!(store.plugins(greedy, false, fetch()), figcraft_collab::message::PluginReply::Bundle { .. })).count();
|
||||
assert!((2..=5).contains(&served), "{served}");
|
||||
for _ in 0..200 {
|
||||
let r = store.plugins(greedy, false, fetch());
|
||||
assert!(matches!(&r, figcraft_collab::message::PluginReply::Failed { reason } if reason == BUSY_DOWNLOADS));
|
||||
}
|
||||
assert!(
|
||||
matches!(store.plugins(ip("198.51.100.2"), false, fetch()), figcraft_collab::message::PluginReply::Bundle { .. }),
|
||||
"others still download"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_read_messages_count_against_a_reading_budget() {
|
||||
let dir = TempDir::new("reading");
|
||||
let cfg = open(dir.path());
|
||||
let store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut busy = Hosting::serve(listener, store, NetLimits { max_reading_block: 64 << 10, max_reading_total: 1 << 20, ..cfg.net() }).unwrap();
|
||||
let mut raw = Raw::connect(busy.addr());
|
||||
raw.send(&hosting());
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)), "small messages are read");
|
||||
let mut big = Document::new("Big");
|
||||
let page = big.first_page().unwrap();
|
||||
big.create(page, NodeKind::Rectangle, &"r".repeat(200_000), 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
let _ = raw.try_send(&ClientMsg::Admin(Admin::Publish { name: "Big".into(), snapshot: Replica::new(big).snapshot().unwrap() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { reason } if reason.contains("busy reading")));
|
||||
until(10, "the reading budget to be given back", || busy.connections() == 0);
|
||||
// Given back: another address reads again.
|
||||
let mut raw = Raw::connect(busy.addr());
|
||||
raw.send(&hosting());
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
busy.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_ipv6_block_takes_at_most_an_eighth_of_the_connections() {
|
||||
let dir = TempDir::new("eighth");
|
||||
let cfg = Config { max_connections: 64, trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut held = Vec::new();
|
||||
for n in 1..=8u16 {
|
||||
let (ws, r) = via_proxy(addr, &format!("2001:db8:9:{:x}::1", n << 8));
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)), "connection {n}");
|
||||
held.push(ws);
|
||||
}
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:9:900::1").1, ServerMsg::Refused { reason } if reason.contains("too many connections")), "64 / 8");
|
||||
assert!(matches!(via_proxy(addr, "2001:db8:a::1").1, ServerMsg::Hosting(_)), "another /48 still gets in");
|
||||
drop(held);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_block_can_use_up_a_shared_budget() {
|
||||
let mut a = Abuse::new(AbuseConfig { open: true, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
|
||||
let t = Instant::now();
|
||||
// A /48 publishes at most an eighth of what everyone may; everyone else keeps the rest.
|
||||
let from_48 = (0..200u16).filter(|n| a.allow(IpAddr::from([0x2001, 0xdb8, 5, n << 8, 0, 0, 0, 0]), Action::Publish, t)).count();
|
||||
assert_eq!(from_48, 40, "four addresses' worth");
|
||||
let others = (0..400u32).filter(|n| a.allow(IpAddr::from((0xC633_6400 + n).to_be_bytes()), Action::Publish, t)).count();
|
||||
assert_eq!(others, 320 - 40, "the rest of everyone's 320 an hour");
|
||||
// Downloads: a /48's burst is at most an eighth of everyone's (checked when compiling, in `store`).
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn problems_stay_bounded_and_a_full_disk_refuses_writes() {
|
||||
let dir = TempDir::new("problems");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (info, ..) = store.publish("Broken", &snapshot("Broken")).unwrap();
|
||||
std::fs::write(dir.path().join("files").join(&info.id).join("snapshot.bin"), b"junk").unwrap();
|
||||
for _ in 0..500 {
|
||||
assert!(Backend::open(&mut store, &info.id).is_err());
|
||||
}
|
||||
assert!(store.problems().len() <= 200, "{} problems kept", store.problems().len());
|
||||
drop(store);
|
||||
// Where the free space can be told, below the threshold nothing more is written.
|
||||
if crate::disk::free_bytes(dir.path()).is_some() {
|
||||
let mut c = config(dir.path()).store();
|
||||
c.min_free_bytes = u64::MAX;
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let e = store.publish("More", &snapshot("More")).unwrap_err();
|
||||
assert!(e.contains("disk is nearly full"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Third review: R1, N3, R2 -------------------------------------------------------------------
|
||||
|
||||
/// Read messages until `pick` takes one (others in between are skipped).
|
||||
fn wait_for<T>(raw: &mut Raw, what: &str, mut pick: impl FnMut(ServerMsg) -> Option<T>) -> T {
|
||||
loop {
|
||||
match raw.next() {
|
||||
ServerMsg::Refused { reason } => panic!("refused while waiting for {what}: {reason}"),
|
||||
m => {
|
||||
if let Some(t) = pick(m) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_office_behind_one_address_works_together() {
|
||||
let dir = TempDir::new("office");
|
||||
let cfg = Config { max_file_mb: Some(4), max_plugin_mb: 1, ..open(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
// A file near the size limit.
|
||||
let mut d = Document::new("Office");
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, &"o".repeat(3_000_000), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let (_, replies) = manage::hosting(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
"",
|
||||
vec![Admin::Publish { name: "Office".into(), snapshot: Replica::new(d).snapshot().unwrap() }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(AdminReply::Hosted { file, edit, .. }) = replies.into_iter().next() else { panic!("not hosted") };
|
||||
// Twenty colleagues, one address, all joining at once: twenty welcomes of 3 MB.
|
||||
let mut people: Vec<Raw> = (0..20).map(|_| Raw::connect(addr)).collect();
|
||||
for (i, p) in people.iter_mut().enumerate() {
|
||||
p.send(&Client::hello(&file.id, &edit.token, &format!("Person {i}"), ""));
|
||||
}
|
||||
let mut clients: Vec<Client> = people
|
||||
.iter_mut()
|
||||
.map(|p| Client::join(wait_for(p, "the welcome", |m| matches!(m, ServerMsg::Welcome { .. }).then_some(m))).unwrap())
|
||||
.collect();
|
||||
// Everyone moves their cursor (twenty people at ~30 a second share the address's budget).
|
||||
for p in people.iter_mut() {
|
||||
for k in 0..60 {
|
||||
p.send(&ClientMsg::Presence(figcraft_collab::Presence { cursor: Some((page, f64::from(k), 0.0)), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
// One pastes 1 MB: the broadcast reaches all twenty.
|
||||
let mut doc = clients[0].document().clone();
|
||||
doc.create(page, NodeKind::Rectangle, &"p".repeat(1_000_000), 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
let batch = clients[0].edit(&doc).unwrap().unwrap();
|
||||
people[0].send(&batch);
|
||||
for (i, p) in people.iter_mut().enumerate() {
|
||||
let seq = wait_for(p, "the paste", |m| {
|
||||
if let ServerMsg::Changes(a) = m {
|
||||
a.changes.iter().any(|c| matches!(c, figcraft_collab::Change::Create { .. })).then_some(a.seq)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
assert!(seq > 0, "person {i}");
|
||||
}
|
||||
// Still all connected.
|
||||
for p in people.iter_mut() {
|
||||
p.send(&ClientMsg::Ping);
|
||||
wait_for(p, "the pong", |m| (m == ServerMsg::Pong).then_some(()));
|
||||
}
|
||||
assert_eq!(server.connections(), 20);
|
||||
drop(clients.drain(..));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_biggest_reader_is_cut_off_when_everyones_reading_budget_runs_out() {
|
||||
count_connection_panics();
|
||||
let dir = TempDir::new("readers");
|
||||
let cfg = open(dir.path());
|
||||
let store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let limits = NetLimits { max_reading_block: 10 << 20, max_reading_total: 300_000, ..cfg.net() };
|
||||
let mut busy = Hosting::serve(listener, store, limits).unwrap();
|
||||
let addr = busy.addr();
|
||||
// A slow sender: it starts a 300 KB message, sends 250 KB of it and stops.
|
||||
let mut hog = Raw::connect(addr);
|
||||
hog.send(&hosting());
|
||||
assert!(matches!(hog.next(), ServerMsg::Hosting(_)));
|
||||
let mut frame = vec![0x82, 0x80 | 127];
|
||||
frame.extend_from_slice(&300_000u64.to_be_bytes());
|
||||
frame.extend_from_slice(&[1, 2, 3, 4]);
|
||||
frame.extend(std::iter::repeat_n(0x55, 250_000));
|
||||
hog.raw_write(&frame);
|
||||
until(10, "the server to read the hog's bytes", || busy.reading_bytes() >= 250_000);
|
||||
// Someone else sends a 100 KB message: the hog is cut off, not them.
|
||||
let mut other = Raw::connect(addr);
|
||||
other.send(&hosting());
|
||||
assert!(matches!(other.next(), ServerMsg::Hosting(_)));
|
||||
let mut d = Document::new("Other");
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, &"q".repeat(100_000), 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
other.send(&ClientMsg::Admin(Admin::Publish { name: "Other".into(), snapshot: Replica::new(d).snapshot().unwrap() }));
|
||||
assert!(matches!(other.next(), ServerMsg::Admin(_)), "the message was read and answered");
|
||||
assert!(hog.closed_within(Duration::from_secs(10)), "the hog's connection was cut");
|
||||
until(10, "the hog's bytes to be given back", || busy.reading_bytes() == 0);
|
||||
assert_eq!(CONNECTION_PANICS.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
busy.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_forgets_addresses_whose_connections_are_gone() {
|
||||
let dir = TempDir::new("forget");
|
||||
let cfg = Config { trusted_proxy: Some("127.0.0.1".into()), ..open(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
for n in 1..=10u8 {
|
||||
let (ws, r) = via_proxy(addr, &format!("203.0.113.{n}"));
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)));
|
||||
drop(ws);
|
||||
}
|
||||
until(10, "the connections to close", || server.connections() == 0);
|
||||
let (_last, _) = via_proxy(addr, "203.0.113.200");
|
||||
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
|
||||
server.stop();
|
||||
}
|
||||
|
|
@ -8,8 +8,11 @@
|
|||
//! that was confirmed and clients resume where they were. There are no user accounts yet: share
|
||||
//! links are the access, and the server key manages files.
|
||||
|
||||
pub mod abuse;
|
||||
pub mod cli;
|
||||
pub mod codes;
|
||||
pub mod config;
|
||||
pub mod directory;
|
||||
pub mod disk;
|
||||
pub mod store;
|
||||
|
||||
|
|
@ -37,6 +40,16 @@ impl Running {
|
|||
self.hosting.connections()
|
||||
}
|
||||
|
||||
/// Bytes waiting to be sent to clients, all connections together.
|
||||
pub fn queued_bytes(&self) -> usize {
|
||||
self.hosting.queued_bytes()
|
||||
}
|
||||
|
||||
/// Blocks of addresses the server keeps counters for (see [`Hosting::blocks_remembered`]).
|
||||
pub fn blocks_remembered(&self) -> usize {
|
||||
self.hosting.blocks_remembered()
|
||||
}
|
||||
|
||||
/// Stop (see the type's docs).
|
||||
pub fn stop(&mut self) {
|
||||
self.hosting.stop();
|
||||
|
|
@ -50,6 +63,9 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
|
|||
for p in store.problems() {
|
||||
log::warn!("{p}");
|
||||
}
|
||||
for w in cfg.warnings() {
|
||||
log::warn!("{w}");
|
||||
}
|
||||
let files = store.len();
|
||||
let addr = cfg.addr()?;
|
||||
let listener = TcpListener::bind(addr).map_err(|e| format!("can't listen on {addr}: {e}"))?;
|
||||
|
|
@ -70,11 +86,23 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
|
|||
Ok(Running { hosting })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod background_tests;
|
||||
#[cfg(test)]
|
||||
mod cli_tests;
|
||||
#[cfg(test)]
|
||||
mod codes_tests;
|
||||
#[cfg(test)]
|
||||
mod directory_tests;
|
||||
#[cfg(test)]
|
||||
mod hardening_tests;
|
||||
#[cfg(test)]
|
||||
mod library_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
#[cfg(test)]
|
||||
mod public_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
#[cfg(test)]
|
||||
mod store_tests;
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ fn publish_enable_update_and_usage() {
|
|||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 2_000, "reports are capped");
|
||||
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
|
|
@ -141,7 +141,7 @@ fn publish_enable_update_and_usage() {
|
|||
drop(server);
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
assert!(matches!(
|
||||
store.library(&edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
|
||||
store.library(std::net::Ipv4Addr::LOCALHOST.into(), &edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
|
||||
LibraryReply::Library { version: 2, .. }
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
//! Your online files against a real server: a file shared online is recorded with its owner
|
||||
//! token; after a restart of the app the server says what became of it (`Admin::Owned`, one
|
||||
//! request for all the files), it opens again as its owner in step with everyone who joined by
|
||||
//! code, its sharing stops and starts from the record, and removing it takes it off the server's
|
||||
//! disk. The request itself: only a file's own token learns about it, junk never hurts, and an
|
||||
//! owner's question opens nothing (a file isn't kept alive by being asked about).
|
||||
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, MAX_OWNED, OpenHosting, PROTOCOL, ServerMsg};
|
||||
use figcraft_engine::Prefs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::test_util::{Raw, TempDir, config, live, pump, same, session};
|
||||
|
||||
fn open(dir: &std::path::Path) -> Config {
|
||||
Config { open_publish: true, ..config(dir) }
|
||||
}
|
||||
|
||||
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// An open hosting connection that names no file.
|
||||
fn hosting(addr: std::net::SocketAddr) -> Raw {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
raw
|
||||
}
|
||||
|
||||
fn owned(raw: &mut Raw, files: Vec<(String, String)>) -> AdminReply {
|
||||
raw.send(&ClientMsg::Admin(Admin::Owned { files }));
|
||||
match raw.next() {
|
||||
ServerMsg::Admin(r) => *r,
|
||||
other => panic!("answered {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ada shares her open file online through the server at `url`: the app with the file live.
|
||||
fn share(url: &str) -> figcraft_engine::Session {
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
|
||||
ex(&mut a, "collab.share", json!({}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().share.as_ref().is_some_and(|sh| sh.links.is_some()));
|
||||
a
|
||||
}
|
||||
|
||||
/// The app quits (its sessions end with it); the next run has only the preferences.
|
||||
fn restart(a: figcraft_engine::Session, name: &str) -> figcraft_engine::Session {
|
||||
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&a.prefs).unwrap()).unwrap();
|
||||
drop(a);
|
||||
let mut next = session(name);
|
||||
next.prefs = prefs;
|
||||
next
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_file_is_its_owners_again_after_the_app_restarts() {
|
||||
let dir = TempDir::new("owned-restart");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let a = share(&url);
|
||||
let code = a.live_summary()["code"].as_str().unwrap().to_string();
|
||||
let file = a.prefs.online_files[0].file.clone();
|
||||
let owner = a.prefs.online_files[0].owner.clone();
|
||||
assert_eq!(a.live().published.as_ref().and_then(|p| p.owner.clone()), Some(owner.clone()));
|
||||
// The app quits. The next run knows the file from its preferences alone.
|
||||
let mut a = restart(a, "Ada");
|
||||
let list = ex(&mut a, "collab.files", json!({}));
|
||||
assert_eq!((&list["files"][0]["file"], &list["files"][0]["state"], &list["files"][0]["code"]), (&json!(file), &json!("unknown"), &json!(code)));
|
||||
// The server says it is there, and still shared.
|
||||
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"], &list["problems"]), (&json!("online"), &json!(true), &json!([])));
|
||||
assert!(list["files"][0]["expires"].as_u64().is_some_and(|e| e > crate::store::now()));
|
||||
assert!(!list.to_string().contains(&owner));
|
||||
// Open it: the owner is in the file, synced, in a tab of its own.
|
||||
let opened = ex(&mut a, "collab.openFile", json!({"file": file}));
|
||||
assert!(!opened.to_string().contains(&owner));
|
||||
pump(&mut [&mut a], "the owner back in", |s| live(s[0]));
|
||||
assert_eq!(a.docs.len(), 2);
|
||||
assert!(!a.live_summary().to_string().contains(&owner), "the token stays in the record");
|
||||
// Someone joins with the code given out before the restart, and sees the owner's edits.
|
||||
let mut b = session("Bo");
|
||||
ex(&mut b, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut b, "collab.join", json!({"code": code}));
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
ex(&mut a, "shape.ellipse", json!({"x": 100, "y": 100, "width": 20, "height": 20}));
|
||||
pump(&mut [&mut a, &mut b], "the edit at Bo's", |s| same(s[0].doc().unwrap(), s[1].doc().unwrap()));
|
||||
// Stop sharing from the list (no tab needed): Bo is out, the owner stays, the code is dead.
|
||||
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "unshare"}))["shared"], false);
|
||||
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active() && live(s[0]));
|
||||
assert_eq!(a.live_summary()["share"]["shared"], false, "the open tab follows");
|
||||
let mut c = session("Cy");
|
||||
ex(&mut c, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut c, "collab.join", json!({"code": code}));
|
||||
pump(&mut [&mut c], "the old code refused", |s| matches!(s[0].live().status, figcraft_engine::live::Status::Failed { .. }));
|
||||
// Start again: a new code for the same file.
|
||||
let again = ex(&mut a, "collab.share", json!({}));
|
||||
let new_code = again["code"].as_str().unwrap().to_string();
|
||||
assert_ne!(new_code, code);
|
||||
assert_eq!(ex(&mut a, "collab.files", json!({}))["files"][0]["code"], new_code);
|
||||
let mut d = session("Di");
|
||||
ex(&mut d, "pref.publishServer", json!({"url": url}));
|
||||
ex(&mut d, "collab.join", json!({"code": new_code}));
|
||||
pump(&mut [&mut a, &mut d], "Di in with the new code", |s| live(s[1]));
|
||||
// Rename, then remove: the file leaves the server's disk and the list; the tab's session ends.
|
||||
ex(&mut a, "collab.manageFile", json!({"file": file, "action": "rename", "name": "Landing"}));
|
||||
assert_eq!(ex(&mut a, "collab.files", json!({"refresh": true}))["files"][0]["name"], "Landing");
|
||||
assert!(dir.path().join("files").join(&file).exists());
|
||||
assert_eq!(ex(&mut a, "collab.manageFile", json!({"file": file, "action": "remove"}))["done"], true);
|
||||
assert!(!dir.path().join("files").join(&file).exists(), "gone from the data directory");
|
||||
assert!(a.prefs.online_files.is_empty() && !a.live().status.active() && a.docs.len() == 2, "the copy stays open");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_expired_or_was_removed_elsewhere_shows_as_gone() {
|
||||
let dir = TempDir::new("owned-gone");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = share(&url);
|
||||
let file = a.prefs.online_files[0].file.clone();
|
||||
ex(&mut a, "collab.leave", json!({}));
|
||||
let mut a = restart(a, "Ada");
|
||||
// The operator removes it.
|
||||
crate::cli::run(&crate::cli::Cli { config: cfg.clone(), command: crate::cli::Command::RemoveFile { file: file.clone() } }).unwrap();
|
||||
let list = ex(&mut a, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["files"][0]["shared"]), (&json!("gone"), &json!(false)));
|
||||
assert!(a.execute("collab.openFile", &json!({"file": file})).unwrap_err().to_string().contains("no longer on its server"));
|
||||
assert_eq!(ex(&mut a, "collab.forgetFile", json!({"file": file}))["onServer"], false);
|
||||
// The server stops: the list says it couldn't be reached and keeps what it has.
|
||||
let mut b = share(&url);
|
||||
ex(&mut b, "collab.leave", json!({}));
|
||||
server.stop();
|
||||
let list = ex(&mut b, "collab.files", json!({"refresh": true}));
|
||||
assert_eq!((&list["files"][0]["state"], &list["problems"][0]["state"]), (&json!("online"), &json!("offline")));
|
||||
assert_eq!(list["files"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_files_own_token_learns_about_it() {
|
||||
let dir = TempDir::new("owned-ask");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let a = share(&format!("ws://{addr}/"));
|
||||
let (file, owner) = (a.prefs.online_files[0].file.clone(), a.prefs.online_files[0].owner.clone());
|
||||
let opened_before = std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap();
|
||||
let mut raw = hosting(addr);
|
||||
let pair = |f: &str, o: &str| (f.to_string(), o.to_string());
|
||||
let reply = owned(
|
||||
&mut raw,
|
||||
vec![pair(&file, &owner), pair(&file, "WrongTokenWrongToken00"), pair("nosuchfile", &owner), pair("", ""), pair("../../etc", "x")],
|
||||
);
|
||||
let AdminReply::Owned(list) = reply else { panic!("answered {reply:?}") };
|
||||
assert_eq!(list.len(), 5, "one answer per pair, in order");
|
||||
let first = list[0].as_ref().expect("its own token");
|
||||
assert_eq!(first.name, "Untitled");
|
||||
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
|
||||
assert!(first.expires.is_some());
|
||||
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
|
||||
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
|
||||
assert_eq!(owned(&mut raw, vec![]), AdminReply::Owned(vec![]));
|
||||
let many = vec![pair(&file, &owner); MAX_OWNED + 1];
|
||||
assert!(matches!(owned(&mut raw, many), AdminReply::Failed { reason } if reason.contains("at most")));
|
||||
assert!(matches!(owned(&mut raw, vec![pair(&file, &owner); MAX_OWNED]), AdminReply::Owned(l) if l.len() == MAX_OWNED));
|
||||
// Junk ids and tokens (long, control characters, other scripts) are just not theirs.
|
||||
let junk = vec![pair(&"x".repeat(100_000), &"y".repeat(100_000)), pair("\u{0}\u{7}", "\u{1F4A5}"), pair(&file, &format!("{owner}\u{0}"))];
|
||||
assert!(matches!(owned(&mut raw, junk), AdminReply::Owned(l) if l.iter().all(Option::is_none)));
|
||||
// On that connection the token still manages nothing: it named no file when it opened.
|
||||
raw.send(&ClientMsg::Admin(Admin::Remove { file: file.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { .. })));
|
||||
assert!(dir.path().join("files").join(&file).exists());
|
||||
// Asking opened nothing: when the file was last opened is as it was.
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("files").join(&file).join("meta.json")).unwrap(), opened_before);
|
||||
// A connection that manages one file with its token may ask too; the server key as well.
|
||||
let mut one = Raw::connect(addr);
|
||||
one.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "test".into(), file: file.clone(), owner: owner.clone() }));
|
||||
assert!(matches!(one.next(), ServerMsg::Hosting(_)));
|
||||
assert!(matches!(owned(&mut one, vec![pair(&file, &owner)]), AdminReply::Owned(l) if l[0].is_some()));
|
||||
drop(a);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_isnt_open_to_the_public_answers_that_nothing_is_owned() {
|
||||
let dir = TempDir::new("owned-private");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let mut raw = hosting(server.addr());
|
||||
assert!(matches!(owned(&mut raw, vec![("f".into(), "t".into())]), AdminReply::Owned(l) if l == [None]));
|
||||
server.stop();
|
||||
}
|
||||
|
|
@ -0,0 +1,495 @@
|
|||
//! A server open to the public: anyone publishes without the key and manages the file with its
|
||||
//! owner token; the caps per address and for everyone, rate limits, idle expiry, blocking,
|
||||
//! reports, the uptime check, `X-Forwarded-For` only from the trusted proxy, and old clients.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{IpAddr, SocketAddr, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, OpenHosting, PROTOCOL, ServerMsg};
|
||||
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
use tungstenite::client::IntoClientRequest;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::abuse::{Abuse, AbuseConfig, Action, MAX_ADDRESSES};
|
||||
use crate::cli::{self, Cli, Command, Sort};
|
||||
use crate::config::Config;
|
||||
use crate::store::{KEY_ONLY, Store};
|
||||
use crate::test_util::{Raw, TempDir, config, join, key, live, pump, session};
|
||||
|
||||
fn open(dir: &std::path::Path) -> Config {
|
||||
Config { open_publish: true, operator_contact: "abuse@example.com".into(), terms_url: "https://example.com/terms".into(), ..config(dir) }
|
||||
}
|
||||
|
||||
fn snapshot(name: &str) -> Vec<figcraft_collab::Change> {
|
||||
let mut d = Document::new(name);
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 100.0, 60.0).unwrap();
|
||||
d.repair();
|
||||
Replica::new(d).snapshot().unwrap()
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn ex(s: &mut figcraft_engine::Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn cli(cfg: &Config, command: Command) -> Result<String, String> {
|
||||
cli::run(&Cli { config: cfg.clone(), command })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyone_publishes_and_the_owner_token_manages_the_file() {
|
||||
let dir = TempDir::new("public");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let url = format!("ws://{addr}/");
|
||||
let mut a = session("Ada");
|
||||
// The server says it is open, who runs it and its terms before anything is uploaded.
|
||||
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
|
||||
assert_eq!(info["open"], true);
|
||||
assert_eq!(info["operator"], "abuse@example.com");
|
||||
assert_eq!(info["terms"], "https://example.com/terms");
|
||||
assert_eq!(info["idleDays"], 30.0);
|
||||
// Published without a key: links, an owner token shown once, and an expiry date.
|
||||
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() && s[0].live().hosting.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
let owner = p.owner.clone().expect("an owner token");
|
||||
assert_eq!(owner.len(), 22, "128 bits");
|
||||
assert!(p.expires.unwrap() > crate::store::now() + 29 * 86_400);
|
||||
let summary = a.live_summary();
|
||||
assert_eq!(summary["published"]["owner"], owner.as_str());
|
||||
assert_eq!(summary["hosting"]["operator"], "abuse@example.com");
|
||||
// The owner token is kept only as a hash.
|
||||
let meta = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("meta.json")).unwrap();
|
||||
assert!(!meta.contains(&owner) && meta.contains(&crate::store::hash(&owner)) && meta.contains("127.0.0.1"), "{meta}");
|
||||
// Someone joining hears about the hosting too (with the file's expiry).
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in, with the hosting", |s| live(s[1]) && s[1].live().hosting.as_ref().is_some_and(|h| h.expires.is_some()));
|
||||
// The owner lists, makes, revokes links and renames the file.
|
||||
let manage = |s: &mut figcraft_engine::Session, extra: Value| {
|
||||
let mut p2 = json!({"server": url, "file": p.file, "owner": owner});
|
||||
p2.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
s.execute("collab.manageFile", &p2)
|
||||
};
|
||||
let links = manage(&mut a, json!({"action": "links"})).unwrap();
|
||||
assert_eq!(links["links"].as_array().unwrap().len(), 2);
|
||||
let made = manage(&mut a, json!({"action": "createLink", "access": "view", "label": "Review"})).unwrap();
|
||||
assert!(made["link"].as_str().unwrap().starts_with("figcraft://join?host="), "{made}");
|
||||
let view_id = links["links"].as_array().unwrap().iter().find(|l| l["access"] == "view").unwrap()["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(manage(&mut a, json!({"action": "revoke", "link": view_id})).unwrap()["done"], true);
|
||||
manage(&mut a, json!({"action": "rename", "name": "Poster v2"})).unwrap();
|
||||
assert!(cli(&cfg, Command::Files { sort: Sort::Size }).unwrap().contains("Poster v2"));
|
||||
// A wrong token, or another file's, manages nothing.
|
||||
let e = a.execute("collab.manageFile", &json!({"server": url, "file": p.file, "owner": "AAAAAAAAAAAAAAAAAAAAAA", "action": "links"}));
|
||||
assert!(e.unwrap_err().to_string().contains("doesn't manage"));
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: p.file.clone(), owner: owner.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Hosting(_)));
|
||||
raw.send(&ClientMsg::Admin(Admin::Remove { file: "0000000000000000".into() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("only its own"))));
|
||||
raw.send(&ClientMsg::Admin(Admin::Listing));
|
||||
assert!(matches!(raw.next(), ServerMsg::Admin(r) if matches!(*r, AdminReply::Failed { ref reason } if reason.contains("server key"))));
|
||||
// Removing it disconnects everyone in it (whoever removed it just leaves: no failure there).
|
||||
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
|
||||
pump(&mut [&mut a, &mut b], "everyone told", |s| s[0].live_summary()["status"] == "off" && s[1].live_summary()["status"] == "failed");
|
||||
assert!(b.message.as_deref().unwrap_or_default().contains("removed"), "{:?}", b.message);
|
||||
assert!(!dir.path().join("files").join(&p.file).exists());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_server_still_needs_its_key() {
|
||||
let dir = TempDir::new("private");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = session("Ada");
|
||||
let info = ex(&mut a, "collab.serverInfo", json!({"server": url}));
|
||||
assert_eq!(info["open"], false);
|
||||
ex(&mut a, "collab.publish", json!({"server": url}));
|
||||
pump(&mut [&mut a], "the refusal", |s| s[0].live_summary()["status"] == "failed");
|
||||
assert!(a.message.as_deref().unwrap_or_default().contains("only from people who have its key"), "{:?}", a.message);
|
||||
assert!(!dir.path().join("files").read_dir().unwrap().any(|_| true), "nothing kept");
|
||||
// The operator's key publishes as before (no owner token, no expiry).
|
||||
let p = crate::test_util::publish(&mut a, dir.path(), server.addr());
|
||||
assert!(p.owner.is_none() && p.expires.is_none());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_per_address_and_for_everyone() {
|
||||
let dir = TempDir::new("caps");
|
||||
let mut c = open(dir.path());
|
||||
c.max_files_per_ip = 2;
|
||||
c.publishes_per_hour = 100;
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (a, b) = (ip("203.0.113.1"), ip("203.0.113.2"));
|
||||
store.publish_open(a, "One", &snapshot("One")).unwrap();
|
||||
store.publish_open(a, "Two", &snapshot("Two")).unwrap();
|
||||
let e = store.publish_open(a, "Three", &snapshot("Three")).unwrap_err();
|
||||
assert!(e.contains("keeps 2 files"), "{e}");
|
||||
store.publish_open(b, "Other", &snapshot("Other")).unwrap();
|
||||
drop(store);
|
||||
// Bytes per address, and for everyone (a byte: nothing fits).
|
||||
let mut tight = c.store();
|
||||
tight.hosting.max_bytes_per_ip = 1;
|
||||
let mut store = Store::open(dir.path(), tight.clone()).unwrap();
|
||||
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
|
||||
assert!(e.contains("MiB it may keep"), "{e}");
|
||||
drop(store);
|
||||
tight.hosting.max_bytes_per_ip = u64::MAX;
|
||||
tight.hosting.max_total_bytes = 1;
|
||||
let mut store = Store::open(dir.path(), tight).unwrap();
|
||||
let e = store.publish_open(ip("203.0.113.3"), "Big", &snapshot("Big")).unwrap_err();
|
||||
assert!(e.contains("server is full"), "{e}");
|
||||
drop(store);
|
||||
// Publishing too often.
|
||||
let mut c3 = open(dir.path());
|
||||
c3.publishes_per_hour = 1;
|
||||
let mut store = Store::open(dir.path(), c3.store()).unwrap();
|
||||
store.publish_open(ip("198.51.100.7"), "Once", &snapshot("Once")).unwrap();
|
||||
let e = store.publish_open(ip("198.51.100.7"), "Twice", &snapshot("Twice")).unwrap_err();
|
||||
assert!(e.contains("too many files published"), "{e}");
|
||||
// Closed to the public: nobody publishes without the key.
|
||||
drop(store);
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_files_published_without_the_key_expire() {
|
||||
let dir = TempDir::new("expiry");
|
||||
let mut c = open(dir.path());
|
||||
c.idle_days = 2.0;
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap();
|
||||
let (fresh, ..) = store.publish_open(ip("203.0.113.9"), "Fresh", &snapshot("Fresh")).unwrap();
|
||||
let (keyed, ..) = store.publish("Operator's", &snapshot("Operator's")).unwrap();
|
||||
store.age(&public.id, 2 * 86_400 + 1);
|
||||
store.age(&keyed.id, 365 * 86_400);
|
||||
store.age(&fresh.id, 86_400);
|
||||
assert_eq!(store.sweep(), vec![public.id.clone()]);
|
||||
let ids: Vec<String> = store.listing(&|_| None).into_iter().map(|r| r.info.id).collect();
|
||||
assert!(!ids.contains(&public.id) && ids.contains(&fresh.id) && ids.contains(&keyed.id));
|
||||
assert!(!dir.path().join("files").join(&public.id).exists());
|
||||
// Opening it counts as using it: idle time starts over.
|
||||
let mut server = figcraft_collab_net::hub::Backend::open(&mut store, &fresh.id).unwrap();
|
||||
store.age(&fresh.id, 0);
|
||||
assert!(store.listing(&|_| None).iter().any(|r| r.info.id == fresh.id && r.expires.unwrap() > crate::store::now() + 86_400));
|
||||
// An open file never expires under someone.
|
||||
store.age(&fresh.id, 10 * 86_400);
|
||||
assert!(store.sweep().is_empty());
|
||||
figcraft_collab_net::hub::Backend::idle(&mut store, &fresh.id, &mut server);
|
||||
assert!(store.sweep().is_empty(), "everyone just left: the idle time starts now");
|
||||
// A startup finds what expired while the server was down.
|
||||
store.age(&fresh.id, 3 * 86_400);
|
||||
drop(store);
|
||||
let store = Store::open(dir.path(), c.store()).unwrap();
|
||||
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limits_and_blocks_refuse_with_a_reason() {
|
||||
let dir = TempDir::new("limits");
|
||||
let mut c = open(dir.path());
|
||||
c.connects_per_minute = 3;
|
||||
let mut server = crate::start(&c).unwrap();
|
||||
let addr = server.addr();
|
||||
let ask = || {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenHosting(OpenHosting {
|
||||
protocol: PROTOCOL,
|
||||
client_version: String::new(),
|
||||
file: String::new(),
|
||||
owner: String::new(),
|
||||
}));
|
||||
raw.next()
|
||||
};
|
||||
for _ in 0..3 {
|
||||
assert!(matches!(ask(), ServerMsg::Hosting(_)));
|
||||
}
|
||||
assert!(matches!(ask(), ServerMsg::Refused { reason } if reason.contains("too many connections from your address")));
|
||||
server.stop();
|
||||
// Messages per address, and blocking.
|
||||
let mut c = open(dir.path());
|
||||
c.messages_per_second = 5;
|
||||
let mut server = crate::start(&c).unwrap();
|
||||
let addr = server.addr();
|
||||
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(_)));
|
||||
let refused = (0..200).find_map(|_| {
|
||||
raw.send(&ClientMsg::Ping);
|
||||
match raw.next() {
|
||||
ServerMsg::Refused { reason } => Some(reason),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
assert!(refused.is_some_and(|r| r.contains("too many messages from your address")));
|
||||
let mut held = Raw::connect(addr);
|
||||
held.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(held.next(), ServerMsg::Hosting(_)));
|
||||
assert!(cli(&c, Command::Block { ip: "127.0.0.1".into() }).unwrap().contains("Blocked"));
|
||||
assert!(matches!(held.next(), ServerMsg::Refused { reason } if reason.contains("no longer accepts")));
|
||||
let mut again = Raw::connect(addr);
|
||||
again.send(&ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() }));
|
||||
assert!(matches!(again.next(), ServerMsg::Refused { .. }));
|
||||
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
|
||||
server.stop();
|
||||
// Kept across a restart (the commands work on the directory itself then).
|
||||
assert_eq!(cli(&c, Command::Blocked).unwrap(), "127.0.0.1\n");
|
||||
assert!(cli(&c, Command::Unblock { ip: "127.0.0.1".into() }).unwrap().contains("Unblocked"));
|
||||
assert!(cli(&c, Command::Block { ip: "2001:db8:1:2:3:4:5:6".into() }).unwrap().contains("Blocked"));
|
||||
assert_eq!(cli(&c, Command::Blocked).unwrap(), "2001:db8:1:2::\n", "an IPv6 address blocks its /64");
|
||||
assert!(cli(&c, Command::Block { ip: "not an address".into() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participants_report_files_to_the_operator() {
|
||||
let dir = TempDir::new("reports");
|
||||
let cfg = open(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let url = format!("ws://{}/", server.addr());
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.publish", json!({"server": url, "name": "Flyer"}));
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
let mut b = join(&p.view, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[1].live().hosting.is_some());
|
||||
assert!(b.execute("collab.report", &json!({"reason": " "})).is_err());
|
||||
ex(&mut b, "collab.report", json!({"reason": "Spam\nwith a link"}));
|
||||
pump(&mut [&mut b], "the answer", |s| s[0].message.as_deref().is_some_and(|m| m.contains("operator")));
|
||||
let reports = cli(&cfg, Command::Reports).unwrap();
|
||||
assert!(reports.contains(&p.file) && reports.contains("Spam with a link") && reports.contains("Flyer"), "{reports}");
|
||||
assert!(cli(&cfg, Command::Files { sort: Sort::Idle }).unwrap().lines().any(|l| l.contains(&p.file) && l.contains("127.0.0.1")));
|
||||
server.stop();
|
||||
// Kept on disk.
|
||||
assert!(cli(&cfg, Command::Reports).unwrap().contains("Spam with a link"));
|
||||
}
|
||||
|
||||
/// Connect with `X-Forwarded-For: forwarded` and open a hosting connection: what the server said.
|
||||
pub(crate) fn via_proxy(addr: SocketAddr, forwarded: &str) -> (tungstenite::WebSocket<TcpStream>, ServerMsg) {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut req = format!("ws://{addr}/").into_client_request().unwrap();
|
||||
req.headers_mut().insert("X-Forwarded-For", forwarded.parse().unwrap());
|
||||
let (mut ws, _) = tungstenite::client::client(req, s).unwrap();
|
||||
let open = ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: String::new(), file: String::new(), owner: String::new() });
|
||||
ws.send(Message::Binary(Bytes::from(message::encode(&open).unwrap()))).unwrap();
|
||||
let reply = loop {
|
||||
if let Message::Binary(b) = ws.read().unwrap() {
|
||||
break message::decode(&b, usize::MAX).unwrap();
|
||||
}
|
||||
};
|
||||
(ws, reply)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarded_addresses_count_only_from_the_trusted_proxy() {
|
||||
let dir = TempDir::new("proxy");
|
||||
let mut c = open(dir.path());
|
||||
c.max_connections_per_ip = 1;
|
||||
c.trusted_proxy = Some("127.0.0.1".into());
|
||||
let mut server = crate::start(&c).unwrap();
|
||||
let addr = server.addr();
|
||||
// Behind the proxy each client is its own address: the last one the proxy added.
|
||||
let (_one, r) = via_proxy(addr, "203.0.113.1");
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)));
|
||||
let (_two, r) = via_proxy(addr, "10.0.0.1, 203.0.113.2");
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)));
|
||||
let (_three, r) = via_proxy(addr, "203.0.113.2, 203.0.113.1");
|
||||
assert!(matches!(r, ServerMsg::Refused { reason } if reason.contains("too many connections")));
|
||||
// Blocking the forwarded address blocks that client only.
|
||||
cli(&c, Command::Block { ip: "203.0.113.9".into() }).unwrap();
|
||||
assert!(matches!(via_proxy(addr, "203.0.113.9").1, ServerMsg::Refused { .. }));
|
||||
assert!(matches!(via_proxy(addr, "203.0.113.10").1, ServerMsg::Hosting(_)));
|
||||
server.stop();
|
||||
// Without a trusted proxy the header is anyone's to forge: the connection's own address counts.
|
||||
c.trusted_proxy = None;
|
||||
let mut server = crate::start(&c).unwrap();
|
||||
let addr = server.addr();
|
||||
let (_one, r) = via_proxy(addr, "203.0.113.1");
|
||||
assert!(matches!(r, ServerMsg::Hosting(_)));
|
||||
let (_two, r) = via_proxy(addr, "203.0.113.2");
|
||||
assert!(matches!(r, ServerMsg::Refused { .. }));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// Send `request` on a raw socket; what came back.
|
||||
fn http(addr: SocketAddr, request: &str) -> String {
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
s.write_all(request.as_bytes()).unwrap();
|
||||
let mut out = String::new();
|
||||
let _ = s.read_to_string(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptime_checks_share_the_port() {
|
||||
let dir = TempDir::new("healthz");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let ok = http(addr, "GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n");
|
||||
assert!(ok.starts_with("HTTP/1.1 200 OK") && ok.ends_with("ok\n"), "{ok}");
|
||||
// Under a path (behind a proxy's prefix), and with a query.
|
||||
assert!(http(addr, "GET /figcraft/healthz?x=1 HTTP/1.1\r\n\r\n").starts_with("HTTP/1.1 200"));
|
||||
// Anything else is a WebSocket handshake or nothing.
|
||||
assert!(!http(addr, "GET /healthzz HTTP/1.1\r\n\r\n").contains("200 OK"));
|
||||
assert!(!http(addr, "POST /healthz HTTP/1.1\r\n\r\n").contains("200 OK"));
|
||||
// Sessions still work beside it.
|
||||
let mut a = session("Ada");
|
||||
let p = crate::test_util::publish(&mut a, dir.path(), addr);
|
||||
assert!(!p.edit.is_empty());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_clients_get_clear_answers() {
|
||||
let dir = TempDir::new("old");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
// An older app tries to publish with a wrong key: it is told the server is open.
|
||||
let e =
|
||||
manage::request(&Endpoint::plain(addr.to_string()), "wrong", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("open to the public"), "{e}");
|
||||
// A hello that doesn't announce `hosting` is never sent what it can't read: after its
|
||||
// welcome, the answer to its ping comes before anything about hosting would.
|
||||
let mut a = session("Ada");
|
||||
let p = crate::test_util::publish(&mut a, dir.path(), addr);
|
||||
let edit = Invite::parse(&p.edit).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
let mut hello = figcraft_collab::Client::hello(&edit.file, &edit.token, "Old", "");
|
||||
if let ClientMsg::Hello(h) = &mut hello {
|
||||
h.client_version = "0.3.0 signals".into();
|
||||
}
|
||||
raw.send(&hello);
|
||||
raw.send(&ClientMsg::Ping);
|
||||
let seen: Vec<ServerMsg> = std::iter::from_fn(|| Some(raw.next())).take_while(|m| *m != ServerMsg::Pong).collect();
|
||||
assert!(seen.iter().any(|m| matches!(m, ServerMsg::Welcome { .. })));
|
||||
assert!(!seen.iter().any(|m| matches!(m, ServerMsg::Hosting(_))), "{seen:?}");
|
||||
// The operator's key still manages everything.
|
||||
let ok =
|
||||
manage::request(&Endpoint::plain(addr.to_string()), &key(dir.path()), vec![Admin::Listing], &NetLimits::default(), Duration::from_secs(5))
|
||||
.unwrap();
|
||||
assert!(matches!(&ok[..], [AdminReply::Listing(rows)] if rows.len() == 1));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_address_table_stays_bounded() {
|
||||
let mut abuse = Abuse::new(AbuseConfig { open: true, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
|
||||
let t = Instant::now();
|
||||
for i in 0..(MAX_ADDRESSES as u32 * 3) {
|
||||
assert!(abuse.allow(IpAddr::from(i.to_be_bytes()), Action::Connect, t + Duration::from_micros(u64::from(i))));
|
||||
}
|
||||
assert!(abuse.len() <= MAX_ADDRESSES);
|
||||
// A remembered address is limited; a private server limits only reports.
|
||||
let me = ip("192.0.2.1");
|
||||
assert!(abuse.allow(me, Action::Connect, t) && !abuse.allow(me, Action::Connect, t));
|
||||
let mut closed = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
|
||||
assert!((0..100).all(|_| closed.allow(me, Action::Connect, t)));
|
||||
assert!((0..10).all(|_| closed.allow(me, Action::Report, t)) && !closed.allow(me, Action::Report, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_mode_has_safe_defaults_and_warns_the_operator() {
|
||||
let private = Config::default();
|
||||
assert_eq!(private.file_mb(), 64, "a private server behaves as before");
|
||||
assert!(private.warnings().is_empty());
|
||||
assert_eq!(private.net().max_per_address, 0);
|
||||
let open = Config { open_publish: true, public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
|
||||
assert_eq!(open.file_mb(), 16, "strangers' files stay small");
|
||||
assert_eq!(open.store().hosting.idle_secs, 30 * 86_400, "idle files expire");
|
||||
assert_eq!(open.net().max_per_address, 32);
|
||||
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
|
||||
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
|
||||
assert_eq!(open.net().max_client_message, 17 << 20);
|
||||
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
|
||||
let w = open.warnings().join("\n");
|
||||
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
|
||||
let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
|
||||
assert!(set.warnings().is_empty(), "{:?}", set.warnings());
|
||||
assert_eq!(set.file_mb(), 8);
|
||||
assert!(Config { trusted_proxy: Some("localhost".into()), ..set.clone() }.check().is_err());
|
||||
assert!(Config { terms_url: "javascript:alert(1)".into(), ..set.clone() }.check().is_err());
|
||||
assert!(Config { idle_days: f64::NAN, ..set }.check().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_connections_never_reach_what_needs_the_key() {
|
||||
let dir = TempDir::new("keyonly");
|
||||
let mut server = crate::start(&open(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut store_side = session("Ada");
|
||||
let keyed = crate::test_util::publish(&mut store_side, dir.path(), addr);
|
||||
let other = Invite::parse(&keyed.edit).unwrap().file;
|
||||
// A file of one's own, with its owner token.
|
||||
let (info, replies) = manage::hosting(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
"",
|
||||
vec![Admin::Publish { name: "Mine".into(), snapshot: snapshot("Mine") }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(info.open);
|
||||
let Some(AdminReply::Hosted { file: mine, owner, .. }) = replies.into_iter().next() else { panic!("not hosted") };
|
||||
let key_only = vec![
|
||||
Admin::Files,
|
||||
Admin::Listing,
|
||||
Admin::Reports,
|
||||
Admin::Blocked,
|
||||
Admin::Block { ip: "203.0.113.1".into() },
|
||||
Admin::Unblock { ip: "203.0.113.1".into() },
|
||||
Admin::Links { file: other.clone() },
|
||||
Admin::CreateLink { file: other.clone(), access: figcraft_collab::Access::Edit, label: String::new() },
|
||||
Admin::Revoke { file: other.clone(), link: "x".into() },
|
||||
Admin::Rename { file: other.clone(), name: "Taken".into() },
|
||||
Admin::Remove { file: other.clone() },
|
||||
];
|
||||
// Without a token, and with the token of another file: every one is refused.
|
||||
for (file, token) in [("", ""), (mine.id.as_str(), owner.as_str())] {
|
||||
let (_, replies) =
|
||||
manage::hosting(&Endpoint::plain(addr.to_string()), file, token, key_only.clone(), &NetLimits::default(), Duration::from_secs(5))
|
||||
.unwrap();
|
||||
for (req, r) in key_only.iter().zip(replies) {
|
||||
assert!(matches!(r, AdminReply::Failed { .. }), "{req:?} answered {r:?}");
|
||||
}
|
||||
}
|
||||
// The keyed file is untouched (its name, both links), nothing is blocked, and the plugin
|
||||
// directory won't take a bundle.
|
||||
let k = key(dir.path());
|
||||
let check = manage::request(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
&k,
|
||||
vec![Admin::Listing, Admin::Links { file: other.clone() }],
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&check[0], AdminReply::Listing(rows) if rows.iter().any(|r| r.info.id == other && r.info.name == "Team file")));
|
||||
assert!(matches!(&check[1], AdminReply::Links(links) if links.len() == 2));
|
||||
assert!(std::fs::read_to_string(dir.path().join("blocked.json")).is_err());
|
||||
let e = manage::plugins(
|
||||
&Endpoint::plain(addr.to_string()),
|
||||
"",
|
||||
figcraft_collab::message::PluginReq::Publish { bundle: vec![1, 2, 3] },
|
||||
&NetLimits::default(),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(e, figcraft_collab::message::PluginReply::Failed { .. }), "{e:?}");
|
||||
// Open-mode answers never carry the server key.
|
||||
assert!(!format!("{info:?}").contains(&k));
|
||||
server.stop();
|
||||
}
|
||||
|
|
@ -34,8 +34,8 @@ fn publish_then_edit_and_view_together() {
|
|||
// Nothing changes it until it is on the server.
|
||||
let e = a.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap_err().to_string();
|
||||
assert!(e.contains("being published"), "{e}");
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
let p = a.live.published.clone().unwrap();
|
||||
pump(&mut [&mut a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
let p = a.live().published.clone().unwrap();
|
||||
assert_eq!(a.docs.len(), 1, "the open tab syncs; no new tab");
|
||||
assert_eq!(a.live_summary()["readOnly"], false);
|
||||
assert!(p.edit.starts_with("figcraft://join?host=127.0.0.1:") && p.view.starts_with("figcraft://join?host=127.0.0.1:"));
|
||||
|
|
@ -43,7 +43,7 @@ fn publish_then_edit_and_view_together() {
|
|||
// Bo joins with the edit link, Cy with the view link.
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
let mut c = join(&p.view, "Cy");
|
||||
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live.peers.len() == 2);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "everyone in", |s| s.iter().all(|x| live(x)) && s[0].live().peers.len() == 2);
|
||||
assert_eq!(b.live_summary()["access"], "edit");
|
||||
assert_eq!(c.live_summary()["access"], "view");
|
||||
assert_eq!(c.live_summary()["readOnly"], true);
|
||||
|
|
@ -64,9 +64,9 @@ fn publish_then_edit_and_view_together() {
|
|||
assert!(c.begin_interaction("Move").is_err());
|
||||
assert!(same(&before, c.doc().unwrap()));
|
||||
// Cy is still seen: presence goes both ways.
|
||||
let cy = c.live.info.as_ref().unwrap().me;
|
||||
let cy = c.live().info.as_ref().unwrap().me;
|
||||
c.execute("collab.cursor", &json!({"x": 12, "y": 34})).unwrap();
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live.presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy's cursor at Ada", |s| s[0].live().presence.get(&cy).and_then(|p| p.cursor).is_some_and(|c| c.1 == 12.0));
|
||||
// A client that skips the app's check is refused by the server.
|
||||
let view = Invite::parse(&p.view).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
|
|
@ -90,7 +90,7 @@ fn publish_then_edit_and_view_together() {
|
|||
pump(&mut [&mut a], "nothing changed", |s| s[0].doc().unwrap().node(rect).is_some_and(|n| n.name == "Card"));
|
||||
// Leaving keeps a local copy.
|
||||
c.execute("collab.leave", &json!({})).unwrap();
|
||||
assert_eq!(c.live.status, Status::Off);
|
||||
assert_eq!(c.live().status, Status::Off);
|
||||
assert!(c.doc().unwrap().contains(rect));
|
||||
assert_eq!(c.live_summary()["readOnly"], false, "the copy is yours to edit");
|
||||
c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
|
|
@ -106,21 +106,21 @@ fn a_restart_mid_session_loses_no_confirmed_edit() {
|
|||
let mut a = session("Ada");
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live.peers.len() == 1);
|
||||
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
|
||||
pump(&mut [&mut a, &mut b], "the rectangle confirmed", |s| s[1].doc().unwrap().contains(rect));
|
||||
let (ada, bo) = (a.live.info.as_ref().unwrap().me, b.live.info.as_ref().unwrap().me);
|
||||
let (ada, bo) = (a.live().info.as_ref().unwrap().me, b.live().info.as_ref().unwrap().me);
|
||||
// The server stops: both reconnect, and what Ada makes meanwhile waits.
|
||||
server.stop();
|
||||
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live.status, Status::Reconnecting { .. })));
|
||||
pump(&mut [&mut a, &mut b], "the drop", |s| s.iter().all(|x| matches!(x.live().status, Status::Reconnecting { .. })));
|
||||
let offline = id_of(&a.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap());
|
||||
cfg.listen = addr.to_string();
|
||||
let mut server = start_again(&cfg);
|
||||
pump(&mut [&mut a, &mut b], "everyone back with everything", |s| {
|
||||
s.iter().all(|x| live(x)) && s[1].doc().unwrap().contains(offline) && s[1].doc().unwrap().contains(rect)
|
||||
});
|
||||
assert_eq!(a.live.info.as_ref().unwrap().me, ada, "Ada resumed as herself");
|
||||
assert_eq!(b.live.info.as_ref().unwrap().me, bo, "so did Bo");
|
||||
assert_eq!(a.live().info.as_ref().unwrap().me, ada, "Ada resumed as herself");
|
||||
assert_eq!(b.live().info.as_ref().unwrap().me, bo, "so did Bo");
|
||||
assert!(same(a.doc().unwrap(), b.doc().unwrap()));
|
||||
// Someone new gets it all from the restarted server.
|
||||
let mut c = join(&p.view, "Cy");
|
||||
|
|
@ -160,20 +160,20 @@ fn revoked_links_stop_working_at_once() {
|
|||
assert_eq!((files.len(), files[0].people), (1, 3));
|
||||
// Revoked: Cy is out at once, the others stay.
|
||||
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
|
||||
matches!(&s[2].live.status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live.peers.len() == 1
|
||||
});
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
|
||||
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
|
||||
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
|
||||
assert!(live(&b));
|
||||
// The link no longer joins.
|
||||
c.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live.status, Status::Failed { reason } if reason.contains("isn't valid")));
|
||||
pump(&mut [&mut c], "the refusal", |s| matches!(&s[0].live().status, Status::Failed { reason } if reason.contains("isn't valid")));
|
||||
// Managing needs the key.
|
||||
let e = manage::request(&endpoint, "WrongWrongWrongWrong00", vec![Admin::Files], &NetLimits::default(), Duration::from_secs(5)).unwrap_err();
|
||||
assert!(e.contains("wrong server key"), "{e}");
|
||||
// Removing the file ends it for everyone in it.
|
||||
assert_eq!(ask(Admin::Remove { file: file.clone() }), AdminReply::Done);
|
||||
pump(&mut [&mut a, &mut b], "everyone out", |s| {
|
||||
s.iter().all(|x| matches!(&x.live.status, Status::Failed { reason } if reason.contains("removed")))
|
||||
s.iter().all(|x| matches!(&x.live().status, Status::Failed { reason } if reason.contains("removed")))
|
||||
});
|
||||
server.stop();
|
||||
}
|
||||
|
|
@ -181,7 +181,10 @@ fn revoked_links_stop_working_at_once() {
|
|||
#[test]
|
||||
fn fuzzed_frames_dont_hurt_the_server() {
|
||||
let dir = TempDir::new("fuzz");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
// Open to the public, so publishing without the key and owner tokens are fuzzed too (with
|
||||
// rates high enough that the noise gets in).
|
||||
let cfg = crate::config::Config { open_publish: true, connects_per_minute: 100_000, messages_per_second: 100_000, ..config(dir.path()) };
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
|
|
@ -200,6 +203,32 @@ fn fuzzed_frames_dont_hurt_the_server() {
|
|||
have: None,
|
||||
usage: Some(message::Usage { file: "Fuzz".into(), components: vec![(NodeId(1), 2)] }),
|
||||
}),
|
||||
ClientMsg::OpenPlugins(message::OpenPlugins { protocol: message::PROTOCOL, key: String::new(), client_version: String::new() }),
|
||||
ClientMsg::Plugins(message::PluginReq::List { query: "tiny".into() }),
|
||||
ClientMsg::OpenHosting(message::OpenHosting {
|
||||
protocol: message::PROTOCOL,
|
||||
client_version: String::new(),
|
||||
file: String::new(),
|
||||
owner: String::new(),
|
||||
}),
|
||||
ClientMsg::OpenHosting(message::OpenHosting {
|
||||
protocol: message::PROTOCOL,
|
||||
client_version: String::new(),
|
||||
file: edit.file.clone(),
|
||||
owner: "AAAAAAAAAAAAAAAAAAAAAA".into(),
|
||||
}),
|
||||
ClientMsg::Admin(Admin::Publish {
|
||||
name: "Fuzz".into(),
|
||||
snapshot: figcraft_collab::Replica::new(figcraft_doc::Document::new("F")).snapshot().unwrap(),
|
||||
}),
|
||||
ClientMsg::Admin(Admin::Rename { file: edit.file.clone(), name: "Fuzzed".into() }),
|
||||
ClientMsg::Admin(Admin::Listing),
|
||||
ClientMsg::Admin(Admin::Block { ip: "203.0.113.1".into() }),
|
||||
ClientMsg::Report { reason: "fuzz".into() },
|
||||
ClientMsg::Plugins(message::PluginReq::Fetch { id: "org.example.tiny".into(), version: None }),
|
||||
ClientMsg::Plugins(message::PluginReq::Publish {
|
||||
bundle: figcraft_plugin::bundle::Bundle { manifest: "{}".into(), wasm: b"\0asm".to_vec(), assets: Vec::new() }.encode(),
|
||||
}),
|
||||
ClientMsg::Ping,
|
||||
]
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ fn a_file_survives_a_crash_and_compacts() {
|
|||
let dir = TempDir::new("crash");
|
||||
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
|
||||
let (id, before, seq) = {
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Crash");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
|
|
@ -131,7 +131,7 @@ fn a_file_survives_a_crash_and_compacts() {
|
|||
fn a_long_log_is_compacted_while_open() {
|
||||
let dir = TempDir::new("compact");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ pub fn key(dir: &Path) -> String {
|
|||
}
|
||||
|
||||
pub fn live(s: &Session) -> bool {
|
||||
s.live.status == Status::Live && s.live.syncing()
|
||||
s.live().status == Status::Live && s.live().syncing()
|
||||
}
|
||||
|
||||
/// `a` publishes its open file to the server at `addr`; the links.
|
||||
|
|
@ -99,8 +99,8 @@ pub fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
|
|||
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
|
||||
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
a.live.published.clone().unwrap()
|
||||
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live().published.is_some());
|
||||
a.live().published.clone().unwrap()
|
||||
}
|
||||
|
||||
pub fn join(link: &str, name: &str) -> Session {
|
||||
|
|
@ -123,6 +123,45 @@ impl Raw {
|
|||
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
|
||||
}
|
||||
|
||||
/// Write just the header of a masked binary frame declaring `len` payload bytes: the server
|
||||
/// refuses an oversized message from its header, before the client would push the payload
|
||||
/// into a socket the server has already closed (a broken pipe on Linux).
|
||||
pub fn send_header(&mut self, len: u64) {
|
||||
let mut h = vec![0x82u8, 0x80 | 127];
|
||||
h.extend_from_slice(&len.to_be_bytes());
|
||||
h.extend_from_slice(&[1, 2, 3, 4]);
|
||||
self.raw_write(&h);
|
||||
}
|
||||
|
||||
/// Write raw bytes on the socket (a hand-made, partial frame).
|
||||
pub fn raw_write(&mut self, bytes: &[u8]) {
|
||||
use std::io::Write as _;
|
||||
self.0.get_mut().write_all(bytes).unwrap();
|
||||
}
|
||||
|
||||
/// The server closed the connection within `limit` (anything it sends meanwhile is skipped).
|
||||
pub fn closed_within(&mut self, limit: Duration) -> bool {
|
||||
use std::io::Read as _;
|
||||
let s = self.0.get_mut();
|
||||
let end = Instant::now() + limit;
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = s.set_read_timeout(Some(Duration::from_millis(200)));
|
||||
while Instant::now() < end {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) => return true,
|
||||
Ok(_) => {}
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
||||
Err(_) => return true,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Send without waiting to be read (`Err` once the server has hung up).
|
||||
pub fn try_send(&mut self, m: &ClientMsg) -> tungstenite::Result<()> {
|
||||
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap())))
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> ServerMsg {
|
||||
loop {
|
||||
if let Message::Binary(b) = self.0.read().unwrap() {
|
||||
|
|
|
|||
|
|
@ -25,4 +25,4 @@ log = { workspace = true }
|
|||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
|
|
|
|||
|
|
@ -90,4 +90,21 @@ impl Services for WebServices {
|
|||
fn system_language(&self) -> Option<String> {
|
||||
web_sys::window().and_then(|w| w.navigator().language())
|
||||
}
|
||||
/// The Gamepad API's controllers with the standard mapping (its button order is the layout's;
|
||||
/// others are skipped). Browsers list controllers once one of their buttons is pressed.
|
||||
fn gamepad_held(&mut self) -> u32 {
|
||||
let Some(pads) = web_sys::window().and_then(|w| w.navigator().get_gamepads().ok()) else { return 0 };
|
||||
let mut held = 0;
|
||||
for pad in pads.iter().filter_map(|p| p.dyn_into::<web_sys::Gamepad>().ok()) {
|
||||
if pad.mapping() != web_sys::GamepadMappingType::Standard {
|
||||
continue;
|
||||
}
|
||||
for (i, b) in pad.buttons().iter().take(32).enumerate() {
|
||||
if b.dyn_into::<web_sys::GamepadButton>().is_ok_and(|b| b.pressed()) {
|
||||
held |= 1 << i;
|
||||
}
|
||||
}
|
||||
}
|
||||
held
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@ webbrowser = { workspace = true }
|
|||
image = { workspace = true }
|
||||
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
|
||||
|
||||
# The system's reduce-motion setting (UISettings.AnimationsEnabled) and interface language
|
||||
# (GlobalizationPreferences.Languages).
|
||||
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
|
||||
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections"] }
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Windows: embed the icon and version info in figcraft.exe (no-op elsewhere).
|
||||
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
|
||||
//! (no-op elsewhere).
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
|
|
@ -14,6 +15,10 @@ fn main() {
|
|||
if let Err(e) = res.compile() {
|
||||
println!("cargo::warning=winresource: {e}");
|
||||
}
|
||||
// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers
|
||||
// nest up to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default();
|
||||
println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI
|
||||
//! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
|
@ -12,6 +12,20 @@ use serde_json::{Value, json};
|
|||
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
|
||||
type CtxSlot = Arc<Mutex<Option<egui::Context>>>;
|
||||
|
||||
const MAX_LINE: usize = 32 * 1024 * 1024;
|
||||
const MAX_CONNECTIONS: usize = 8;
|
||||
const MAX_QUEUED_REQUESTS: usize = 8;
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
|
||||
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
target.retain(|request| !request.is_cancelled());
|
||||
let mut queue = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
queue.retain(|request| !request.is_cancelled());
|
||||
let count = queue.len().min(MAX_QUEUED_REQUESTS.saturating_sub(target.len()));
|
||||
target.extend(queue.drain(..count));
|
||||
}
|
||||
|
||||
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
|
|
@ -23,9 +37,9 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
|||
};
|
||||
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
|
||||
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
|
||||
let mut workers = Vec::new();
|
||||
for stream in listener.incoming().flatten() {
|
||||
let (q, c) = (queue.clone(), ctx.clone());
|
||||
let _ = std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || serve(stream, q, c));
|
||||
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
|
|
@ -33,37 +47,77 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
|||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, queue: Queue, ctx: CtxSlot) {
|
||||
let Ok(read_half) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let reader = BufReader::new(read_half);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
|
||||
workers.retain(|worker| !worker.is_finished());
|
||||
if workers.len() >= MAX_CONNECTIONS {
|
||||
return;
|
||||
}
|
||||
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
|
||||
let _ = serve(stream, queue, ctx, timeout);
|
||||
}) {
|
||||
Ok(worker) => workers.push(worker),
|
||||
Err(e) => log::error!("control connection thread: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
|
||||
out.set_read_timeout(Some(timeout))?;
|
||||
out.set_write_timeout(Some(timeout))?;
|
||||
let mut reader = BufReader::new(out.try_clone()?);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
if (&mut reader).take((MAX_LINE + 1) as u64).read_until(b'\n', &mut line)? == 0 {
|
||||
break;
|
||||
}
|
||||
if line.last() == Some(&b'\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.len() > MAX_LINE {
|
||||
writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?;
|
||||
break;
|
||||
}
|
||||
let Ok(line) = std::str::from_utf8(&line) else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(v) => {
|
||||
let id = v.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(ControlRequest { id: id.clone(), method, params, reply: tx });
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pending.retain(|request| !request.is_cancelled());
|
||||
if pending.len() >= MAX_QUEUED_REQUESTS {
|
||||
drop(pending);
|
||||
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
|
||||
continue;
|
||||
}
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
|
||||
drop(pending);
|
||||
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_secs(60)) {
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(r) => match r.result {
|
||||
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
|
||||
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
|
||||
},
|
||||
Err(_) => json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}),
|
||||
Err(_) => {
|
||||
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
writeln!(out, "{reply}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "control_server_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::net::Shutdown;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
fn pair() -> (TcpStream, TcpStream) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
client.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
(client, listener.accept().unwrap().0)
|
||||
}
|
||||
|
||||
fn queue() -> Queue {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn context() -> CtxSlot {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
fn reply(reader: &mut BufReader<TcpStream>) -> Value {
|
||||
let mut line = String::new();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn request() -> ControlRequest {
|
||||
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_oversized_unterminated_frame_without_dispatch() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
// Generate bytes as they are written; no giant input string and no terminating newline.
|
||||
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "message too large"}));
|
||||
assert_eq!(reader.read(&mut [0]).unwrap(), 0);
|
||||
worker.join().unwrap().unwrap();
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_frames_keep_their_order_ids_and_parameters() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader)["ok"], false);
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
assert_eq!(r.id, "test");
|
||||
assert_eq!(r.method, "engine.execute");
|
||||
assert_eq!(r.params, json!({"command": "shape.rectangle"}));
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap();
|
||||
assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| request()));
|
||||
let mut ui_queue = (0..MAX_QUEUED_REQUESTS - 1).map(|_| request()).collect();
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS - 1);
|
||||
pending.lock().unwrap().push(request());
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"}));
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
|
||||
ui_queue.clear();
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_partial_frame_connections_expire() {
|
||||
for bytes in [b"".as_slice(), b"{\"id\":"] {
|
||||
let (mut client, server) = pair();
|
||||
let (done, finished) = mpsc::channel();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));
|
||||
client.write_all(bytes).unwrap();
|
||||
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
let pending = queue();
|
||||
let ctx = context();
|
||||
let mut workers = Vec::new();
|
||||
let mut clients = Vec::new();
|
||||
for _ in 0..MAX_CONNECTIONS {
|
||||
let (client, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
clients.push(client);
|
||||
}
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
let (mut extra, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
for client in clients {
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
}
|
||||
let start = Instant::now();
|
||||
while workers.iter().any(|worker| !worker.is_finished()) {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let (mut client, server) = pair();
|
||||
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(workers.len(), 1);
|
||||
client.write_all(b"invalid\n").unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
workers.pop().unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_that_stops_reading_cannot_hold_a_worker() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let (done, finished) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!("x".repeat(8 * 1024 * 1024))) }).unwrap();
|
||||
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_limit_sized_frame_is_accepted_at_eof() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";
|
||||
client.write_all(message).unwrap();
|
||||
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap();
|
||||
client.shutdown(Shutdown::Write).unwrap();
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
assert_eq!(r.id, 1);
|
||||
assert_eq!(r.method, "ui.inspect");
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client)), json!({"id": 1, "ok": true, "result": {}}));
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = FigApp::new(figcraft_engine::Session::new(), Box::new(NoServices));
|
||||
let window = windows::open(&mut app, &ctx, Some(0), None, None).unwrap();
|
||||
let pending = queue();
|
||||
let mut clients = Vec::new();
|
||||
let mut workers = Vec::new();
|
||||
for id in 0..MAX_QUEUED_REQUESTS {
|
||||
let (mut client, server) = pair();
|
||||
let q = pending.clone();
|
||||
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));
|
||||
writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();
|
||||
clients.push(BufReader::new(client));
|
||||
}
|
||||
let start = Instant::now();
|
||||
while pending.lock().unwrap().len() < MAX_QUEUED_REQUESTS {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
drain_queue(&pending, &mut app.control_queue);
|
||||
control::drain(&mut app, &ctx);
|
||||
assert_eq!(app.control_queue.len(), MAX_QUEUED_REQUESTS, "the other window has not drawn");
|
||||
for client in &mut clients {
|
||||
assert!(reply(client)["error"].as_str().unwrap().contains("timed out"));
|
||||
client.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
}
|
||||
assert!(app.control_queue.iter().all(ControlRequest::is_cancelled));
|
||||
let expired = app.control_queue.pop().unwrap();
|
||||
// Include expired requests in the socket queue, too: admission must reclaim their slots.
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
|
||||
let r = request();
|
||||
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
r
|
||||
}));
|
||||
let (mut client, server) = pair();
|
||||
let q = pending.clone();
|
||||
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));
|
||||
client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();
|
||||
let start = Instant::now();
|
||||
while !pending.lock().unwrap().iter().any(|r| r.id == 100) {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
drain_queue(&pending, &mut app.control_queue);
|
||||
assert_eq!(app.control_queue.len(), 1, "expired UI requests no longer occupy the cap");
|
||||
control::drain(&mut app, &ctx);
|
||||
let mut client = BufReader::new(client);
|
||||
assert_eq!(reply(&mut client)["ok"], true);
|
||||
client.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
assert!(app.control_queue.is_empty());
|
||||
// A window that finally draws must discard its expired command before dispatch, too.
|
||||
let documents = app.session.docs.len();
|
||||
app.windows.current = window;
|
||||
app.control_queue.push(expired);
|
||||
control::drain(&mut app, &ctx);
|
||||
assert!(app.control_queue.is_empty());
|
||||
assert_eq!(app.session.docs.len(), documents);
|
||||
for worker in workers {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
//! Controllers for the prototype player's key/gamepad triggers: the buttons held, as bits at their
|
||||
//! standard gamepad layout indices (`figcraft_doc::proto::GAMEPAD_BUTTONS`).
|
||||
//! Windows reads Windows.Gaming.Input (Xbox-compatible controllers, through the `windows` crate the
|
||||
//! app already uses). macOS, Linux and FreeBSD report none yet: the usual Rust controller library
|
||||
//! links the system's libudev on Linux, which the pure-Rust builds avoid.
|
||||
|
||||
/// The standard layout's buttons held on any controller (bit i: index i).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn held() -> u32 {
|
||||
use windows::Gaming::Input::{Gamepad, GamepadButtons as B};
|
||||
// The standard index of each button Windows reports; the triggers are analog (down past halfway).
|
||||
const MAP: [(B, u32); 14] = [
|
||||
(B::A, 0),
|
||||
(B::B, 1),
|
||||
(B::X, 2),
|
||||
(B::Y, 3),
|
||||
(B::LeftShoulder, 4),
|
||||
(B::RightShoulder, 5),
|
||||
(B::View, 8),
|
||||
(B::Menu, 9),
|
||||
(B::LeftThumbstick, 10),
|
||||
(B::RightThumbstick, 11),
|
||||
(B::DPadUp, 12),
|
||||
(B::DPadDown, 13),
|
||||
(B::DPadLeft, 14),
|
||||
(B::DPadRight, 15),
|
||||
];
|
||||
let Ok(pads) = Gamepad::Gamepads() else { return 0 };
|
||||
let mut held = 0;
|
||||
for pad in pads {
|
||||
let Ok(r) = pad.GetCurrentReading() else { continue };
|
||||
for (b, i) in MAP {
|
||||
if r.Buttons.contains(b) {
|
||||
held |= 1 << i;
|
||||
}
|
||||
}
|
||||
if r.LeftTrigger > 0.5 {
|
||||
held |= 1 << 6;
|
||||
}
|
||||
if r.RightTrigger > 0.5 {
|
||||
held |= 1 << 7;
|
||||
}
|
||||
}
|
||||
held
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn held() -> u32 {
|
||||
0
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
//! Which graphics API draws the window.
|
||||
//!
|
||||
//! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan
|
||||
//! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens:
|
||||
//! an access violation inside the vendor's driver, which no guard of ours can catch (seen on a laptop
|
||||
//! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows
|
||||
//! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver.
|
||||
//!
|
||||
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
|
||||
//! systems other than Windows keep eframe's defaults.
|
||||
|
||||
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
/// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one.
|
||||
const WINDOWS: Backends = Backends::DX12.union(Backends::GL);
|
||||
|
||||
/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`]
|
||||
/// on Windows, else eframe's `defaults`.
|
||||
pub fn backends(requested: Option<Backends>, windows: bool, defaults: Backends) -> Backends {
|
||||
match requested {
|
||||
Some(requested) => requested,
|
||||
None if windows => WINDOWS,
|
||||
None => defaults,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer wgpu [`backends`] instead of eframe's defaults.
|
||||
pub fn configure(options: &mut WgpuConfiguration) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup {
|
||||
let offered = &mut create.instance_descriptor.backends;
|
||||
*offered = backends(Backends::from_env(), cfg!(windows), *offered);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan
|
||||
//! driver crashed the app as its window opened), and nothing changed anywhere else.
|
||||
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
use crate::graphics::backends;
|
||||
|
||||
/// What eframe offers when `WGPU_BACKEND` isn't set.
|
||||
const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL);
|
||||
|
||||
#[test]
|
||||
fn windows_is_offered_direct3d_and_not_vulkan() {
|
||||
let offered = backends(None, true, DEFAULTS);
|
||||
assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window");
|
||||
assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver");
|
||||
assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_backend_names_the_backend_on_every_system() {
|
||||
for windows in [true, false] {
|
||||
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
|
||||
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_systems_keep_eframes_defaults() {
|
||||
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
|
||||
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
|
||||
}
|
||||
|
|
@ -11,13 +11,21 @@
|
|||
|
||||
mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
|
||||
#[cfg(test)]
|
||||
mod args_tests;
|
||||
#[cfg(test)]
|
||||
mod graphics_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_online_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
#[cfg(test)]
|
||||
mod services_tests;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
|
@ -46,9 +54,7 @@ impl eframe::App for App {
|
|||
}
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
fig.control_queue.extend(q.drain(..));
|
||||
drop(q);
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
// windows are open).
|
||||
figcraft_ui_egui::windows::on_close_request(fig, ctx);
|
||||
|
|
@ -126,22 +132,34 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
let recovered = recovery_root.as_deref().map_or(0, |r| recovery::recover(r, &mut session));
|
||||
let recovery: recovery::Shared = Arc::new(Mutex::new(recovery_root.as_deref().and_then(recovery::Recovery::start)));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
let recovery = recovery::Shared::default();
|
||||
let svc = services::Desktop::load(recovery.clone());
|
||||
if let Some(prefs) = svc.prefs.clone() {
|
||||
session.prefs = prefs;
|
||||
}
|
||||
let recovered = recovery_root.as_deref().map(|r| recovery::recover_all(r, &mut session)).unwrap_or_default();
|
||||
let mut copies = recovery_root.as_deref().and_then(recovery::Recovery::start);
|
||||
// Copies of online files stay until their files arrive from their servers.
|
||||
if let Some(r) = copies.as_mut() {
|
||||
r.adopt(recovered.waiting);
|
||||
}
|
||||
*recovery::lock(&recovery) = copies;
|
||||
// Plugins installed from a server's plugin directory live with the user's settings.
|
||||
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
|
||||
// Invite links open this app (Windows registers per user at run time; installed builds by
|
||||
// default, never a build from source unless asked).
|
||||
session.live.links = figcraft_collab_net::links::system();
|
||||
session.invite_links_startup();
|
||||
if session.docs.is_empty() {
|
||||
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
|
||||
// tips and templates; later runs start in a new file.
|
||||
let first_run = svc.prefs.is_none() && args.join.is_none();
|
||||
if session.docs.is_empty() && !first_run {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.ui.home = false;
|
||||
fig.note_recovered(recovered);
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
}
|
||||
|
|
@ -167,7 +185,8 @@ fn main() -> eframe::Result {
|
|||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@
|
|||
//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
|
||||
//! whose lock is free belong to runs that ended without closing: their copies open again as
|
||||
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
|
||||
//!
|
||||
//! A document that is one of your online files has a note beside its copy (`<uid>.online`, written
|
||||
//! after the copy it describes): the server, the file's id, and who the app was in the file with
|
||||
//! the last change of the server's it had; never a token. The next start joins that file again
|
||||
//! as its owner (`collab.recoverFile`) and **keeps the copy until the outcome is known**: the
|
||||
//! server has everything in it (the copy goes), the copy's own unconfirmed changes were put on
|
||||
//! top of the server's file and sent, the copy was opened beside the file because both had
|
||||
//! changed, or the file couldn't be joined and the copy was opened as any other. Whatever a copy
|
||||
//! became is copied into this run's folder before the old copy is removed. Without the note, or
|
||||
//! when the file is no longer recorded, the copy opens as any other.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
|
|
@ -35,6 +45,25 @@ pub struct Recovery {
|
|||
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
|
||||
/// When (UI clock) the next copies are due.
|
||||
next: f64,
|
||||
/// Copies of online files from earlier runs, kept until their outcome is known.
|
||||
waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
/// A copy of one of your online files whose file is being joined again as session `session`.
|
||||
pub struct Waiting {
|
||||
session: u64,
|
||||
copy: PathBuf,
|
||||
/// The lock of the folder it is in: no other start takes the copy meanwhile.
|
||||
_lock: Arc<File>,
|
||||
}
|
||||
|
||||
/// What [`recover_all`] brought back.
|
||||
#[derive(Default)]
|
||||
pub struct Recovered {
|
||||
/// Documents opened from their copies.
|
||||
pub copies: usize,
|
||||
/// Copies of online files waiting for their file ([`Recovery::adopt`]).
|
||||
pub waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
impl Recovery {
|
||||
|
|
@ -44,29 +73,61 @@ impl Recovery {
|
|||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::create(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0 })
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
}
|
||||
|
||||
/// Keep earlier runs' copies of online files until their outcome is known.
|
||||
pub fn adopt(&mut self, waiting: Vec<Waiting>) {
|
||||
self.waiting.extend(waiting);
|
||||
}
|
||||
|
||||
/// Copy the documents that changed since their last copy and drop the copies of documents that
|
||||
/// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
|
||||
/// an idle window can sleep).
|
||||
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
|
||||
self.settle(s);
|
||||
// A join on its way settles within its timeouts: look again soon.
|
||||
let settling = (!self.waiting.is_empty()).then_some(0.5);
|
||||
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
|
||||
if now < self.next {
|
||||
return pending.then_some(self.next - now);
|
||||
return pending.then_some(self.next - now).or(settling);
|
||||
}
|
||||
self.write(s, false);
|
||||
self.next = now + INTERVAL;
|
||||
None
|
||||
settling
|
||||
}
|
||||
|
||||
/// Remove earlier runs' copies whose outcome is known, once what they became is copied here.
|
||||
fn settle(&mut self, s: &Session) {
|
||||
if self.waiting.iter().all(|w| s.live.recovering(w.session)) {
|
||||
return;
|
||||
}
|
||||
self.write(s, true);
|
||||
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) =
|
||||
std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session));
|
||||
self.waiting = waiting;
|
||||
for w in done {
|
||||
let _ = std::fs::remove_file(&w.copy);
|
||||
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
|
||||
// The folder goes with its last copy (while another waits there its lock is held).
|
||||
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1);
|
||||
drop(w);
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy every document with unsaved changes; `wait`: before returning (after a panic).
|
||||
pub fn write(&mut self, s: &Session, wait: bool) {
|
||||
for d in &s.docs {
|
||||
let path = self.dir.join(format!("{}.figcraft", d.uid));
|
||||
let note = path.with_extension(ONLINE);
|
||||
if !needs_copy(d) {
|
||||
if self.written.remove(&d.uid).is_some() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(¬e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -76,7 +137,20 @@ impl Recovery {
|
|||
continue;
|
||||
}
|
||||
let doc = d.doc.clone();
|
||||
let job = move || write_atomic(&path, &doc);
|
||||
// Which online file it is and what of the server's this very copy has (a line each:
|
||||
// the server, the file's id, then the session, client id and last change), while it
|
||||
// is one. Written after the copy, never before: a note must not promise more of the
|
||||
// server's changes than its copy holds.
|
||||
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
|
||||
let job = move || {
|
||||
if !write_atomic(&path, &doc) {
|
||||
return;
|
||||
}
|
||||
let _ = match online {
|
||||
Some(text) => std::fs::write(¬e, text),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
};
|
||||
if wait {
|
||||
job();
|
||||
entry.1 = None;
|
||||
|
|
@ -92,12 +166,14 @@ impl Recovery {
|
|||
let keep = open.contains(uid);
|
||||
if !keep {
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}")));
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose.
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
let Self { dir, _lock, written, .. } = self;
|
||||
// Windows removes a folder only once nothing in it is open: copies being written first.
|
||||
|
|
@ -109,35 +185,65 @@ impl Recovery {
|
|||
}
|
||||
}
|
||||
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
|
||||
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
|
||||
/// file isn't recorded any more, or is open already): then it opens as any other copy.
|
||||
fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
let note = std::fs::read_to_string(file.with_extension(ONLINE)).ok()?;
|
||||
let mut lines = note.lines().map(str::trim);
|
||||
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
|
||||
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
|
||||
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
|
||||
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
|
||||
s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64()
|
||||
}
|
||||
|
||||
/// A document whose changes would be lost (a branch tab's edits are in its main file).
|
||||
fn needs_copy(d: &figcraft_engine::DocState) -> bool {
|
||||
d.is_dirty() && d.branch_of.is_none()
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) {
|
||||
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return };
|
||||
/// Write `doc` to `path` through a temporary file; false when it couldn't be written (the copy
|
||||
/// before it is still there).
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return false };
|
||||
let tmp = path.with_extension("tmp");
|
||||
if std::fs::write(&tmp, bytes).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
|
||||
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
|
||||
/// unsaved copies.
|
||||
#[cfg(test)]
|
||||
pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
recover_all(root, s).copies
|
||||
}
|
||||
|
||||
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
|
||||
/// A copy that can't be read stays where it is. Returns how many documents came back.
|
||||
pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return 0 };
|
||||
let mut n = 0;
|
||||
let mut open = |file: &Path, s: &mut Session| {
|
||||
if file.extension().is_some_and(|e| e == "figcraft") && s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
n += 1;
|
||||
/// A copy that can't be read stays where it is. A copy of one of your online files is kept
|
||||
/// instead while its file is joined again ([`Recovered::waiting`]).
|
||||
pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
let mut out = Recovered::default();
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return out };
|
||||
let mut open = |file: &Path, lock: Option<&Arc<File>>, s: &mut Session| {
|
||||
if file.extension().is_none_or(|e| e != "figcraft") {
|
||||
return;
|
||||
}
|
||||
// One of your online files comes back from its server, its copy kept until then.
|
||||
if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) {
|
||||
out.waiting.push(Waiting { session, copy: file.to_path_buf(), _lock: lock.clone() });
|
||||
} else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
out.copies += 1;
|
||||
let _ = std::fs::remove_file(file);
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if !path.is_dir() {
|
||||
open(&path, s);
|
||||
open(&path, None, s);
|
||||
continue;
|
||||
}
|
||||
// Still running (another window of the app), or not ours to take.
|
||||
|
|
@ -145,19 +251,26 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
|
|||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
}
|
||||
let lock = Arc::new(lock);
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
|
||||
for f in &files {
|
||||
if f.extension().is_some_and(|e| e == "tmp") {
|
||||
// A copy cut off mid-write; the one before it is still there.
|
||||
let _ = std::fs::remove_file(f);
|
||||
} else {
|
||||
open(f, s);
|
||||
open(f, Some(&lock), s);
|
||||
}
|
||||
}
|
||||
drop(lock);
|
||||
// Empty now unless a copy couldn't be read (kept for the next start).
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
let _ = std::fs::remove_dir(&path);
|
||||
// The notes go with their copies (one whose copy stays, stays with it).
|
||||
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
// Empty now unless a copy couldn't be read, or waits for its file (kept, its lock held).
|
||||
if Arc::strong_count(&lock) == 1 {
|
||||
drop(lock);
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
let _ = std::fs::remove_dir(&path);
|
||||
}
|
||||
}
|
||||
n
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
//! Recovery of your online files never loses an edit. The copy of a document that was one of them
|
||||
//! when a run ended without closing has a note beside it (the server, the file, who the app was
|
||||
//! there and the server's last change it had; never a token). The next start joins the file again
|
||||
//! as its owner and keeps the copy until the outcome is known: the copy is ahead of the server
|
||||
//! (its changes are applied on top and sent, as the client the app was), the same (it goes), both
|
||||
//! changed (it opens beside the file as a recovered copy), or the server can't be reached (it
|
||||
//! opens as any other copy). Without the record the copy opens as any other.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_engine::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Resume, Role, SessionInfo, Status};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover, recover_all};
|
||||
|
||||
const SERVER: &str = "wss://share.example/";
|
||||
const OWNER: &str = "OwnerSecretOwnerSecret";
|
||||
const EDIT: &str = "figcraft://join?server=wss://share.example/&file=f1&token=EditSecretEditSecret00";
|
||||
|
||||
/// What the link was asked, what the app sent, and the events handed out next (as the session
|
||||
/// started last).
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
joined: Vec<(String, Option<Resume>)>,
|
||||
sent: Vec<ClientMsg>,
|
||||
events: Vec<Event>,
|
||||
session: u64,
|
||||
}
|
||||
|
||||
struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, name: &str) -> Result<(), String> {
|
||||
self.join_resuming(session, link, name, None)
|
||||
}
|
||||
fn join_resuming(&mut self, session: u64, link: &str, _: &str, resume: Option<Resume>) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push((link.to_string(), resume));
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, session: u64, _: PublishOptions) -> Result<(), String> {
|
||||
self.0.lock().unwrap().session = session;
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, msg: ClientMsg) {
|
||||
self.0.lock().unwrap().sent.push(msg);
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Script>>;
|
||||
|
||||
fn session() -> (Session, Shared) {
|
||||
let mut s = Session::new();
|
||||
let script = Shared::default();
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
(s, script)
|
||||
}
|
||||
|
||||
fn root(name: &str) -> PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-recovery-online-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&r);
|
||||
r
|
||||
}
|
||||
|
||||
fn info() -> SessionInfo {
|
||||
SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Homepage".into(),
|
||||
pages: vec![],
|
||||
link: EDIT.into(),
|
||||
url: String::new(),
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Someone joins `server` (as the client `resume` names, if it knows it): their id and welcome.
|
||||
fn join(server: &mut Server, resume: Option<Resume>) -> (u32, ServerMsg) {
|
||||
let ClientMsg::Hello(mut hello) = Client::hello("f1", "", "Me", "") else { panic!("no hello") };
|
||||
hello.resume = resume;
|
||||
let (id, outs) = server.join(hello).unwrap();
|
||||
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
(id, welcome)
|
||||
}
|
||||
|
||||
/// What the app sent since the last call reaches the server; whether every batch was accepted.
|
||||
/// The server's confirmations wait for the app's next poll.
|
||||
fn deliver(script: &Shared, server: &mut Server, from: u32) -> bool {
|
||||
let sent = std::mem::take(&mut script.lock().unwrap().sent);
|
||||
let mut accepted = true;
|
||||
for m in sent {
|
||||
for o in server.receive(from, m) {
|
||||
match o {
|
||||
Outgoing::To(_, ServerMsg::Reject { .. }) => accepted = false,
|
||||
Outgoing::All(m @ ServerMsg::Changes { .. }) => script.lock().unwrap().events.push(Event::Sync(m)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
accepted
|
||||
}
|
||||
|
||||
fn layers(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.node(doc.pages[0]).map_or(0, |p| p.children.len())
|
||||
}
|
||||
|
||||
/// A run whose open file was shared online: the app, what it sent, the server and the app's id
|
||||
/// there. One rectangle is on the server.
|
||||
fn shared() -> (Session, Shared, Server, u32) {
|
||||
let (mut s, script) = session();
|
||||
s.execute("pref.publishServer", &json!({"url": SERVER})).unwrap();
|
||||
s.execute("file.rename", &json!({"name": "Homepage"})).unwrap();
|
||||
s.execute("collab.share", &json!({})).unwrap();
|
||||
let published = Published {
|
||||
server: "share.example".into(),
|
||||
file: "f1".into(),
|
||||
name: "Homepage".into(),
|
||||
edit: EDIT.into(),
|
||||
view: String::new(),
|
||||
owner: Some(OWNER.into()),
|
||||
expires: None,
|
||||
};
|
||||
let mut server = Server::new(s.doc().unwrap().clone(), "", Limits::default());
|
||||
let (me, welcome) = join(&mut server, None);
|
||||
script.lock().unwrap().events.extend([Event::Published(published), Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
|
||||
s.live_poll();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(deliver(&script, &mut server, me));
|
||||
s.live_poll();
|
||||
assert_eq!(layers(server.document()), 1);
|
||||
(s, script, server, me)
|
||||
}
|
||||
|
||||
/// The run ends without closing, its copies written; the folder it leaves (as another process's).
|
||||
fn crash(s: &Session, root: &Path) -> PathBuf {
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.write(s, true);
|
||||
drop(r);
|
||||
let left = root.join("crashed");
|
||||
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
|
||||
left
|
||||
}
|
||||
|
||||
fn copies(dir: &Path, ext: &str) -> usize {
|
||||
std::fs::read_dir(dir).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == ext)).count())
|
||||
}
|
||||
|
||||
/// The next run: only the preferences survive. Its copies of online files wait for their files.
|
||||
fn restart(crashed: &Session, root: &Path) -> (Session, Shared, Recovery, usize) {
|
||||
let (mut next, script) = session();
|
||||
next.prefs = serde_json::from_value(serde_json::to_value(&crashed.prefs).unwrap()).unwrap();
|
||||
let recovered = recover_all(root, &mut next);
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.adopt(recovered.waiting);
|
||||
(next, script, r, recovered.copies)
|
||||
}
|
||||
|
||||
/// The file arrives in the next run from `server`, as the join asked.
|
||||
fn arrive(next: &mut Session, script: &Shared, server: &mut Server) -> u32 {
|
||||
let resume = script.lock().unwrap().joined.last().and_then(|j| j.1);
|
||||
let (id, welcome) = join(server, resume);
|
||||
script.lock().unwrap().events.extend([Event::Welcome { info: info(), peers: vec![] }, Event::Sync(welcome)]);
|
||||
next.live_poll();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
|
||||
let root = root("ahead");
|
||||
let (mut s, script, mut server, me) = shared();
|
||||
// An edit the server never got (the connection was down, or the app died first).
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
|
||||
let at = s.online_of(s.docs[0].uid).unwrap().2;
|
||||
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
|
||||
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
|
||||
let (mut next, script, mut r, opened) = restart(&s, &root);
|
||||
assert_eq!(opened, 0, "nothing comes back as a plain copy");
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [(link, resume)] = sc.joined.as_slice() else { panic!("joined {}", sc.joined.len()) };
|
||||
assert_eq!(link, EDIT, "joined again as its owner");
|
||||
assert_eq!(*resume, Some(Resume { seq: u64::MAX, ..at }), "as the client it was, asking for the whole file");
|
||||
}
|
||||
// The copy stays until the outcome is known.
|
||||
assert!(r.tick(&next, 0.0).is_some(), "it looks again while the file is on its way");
|
||||
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1));
|
||||
// The file arrives: the server lacks the ellipse; it goes on top as one edit and is sent.
|
||||
let id = arrive(&mut next, &script, &mut server);
|
||||
assert_eq!(id, me, "the server gave the id back");
|
||||
assert_eq!(next.message.take().as_deref(), Some("Recovered your changes to Homepage that hadn't reached the server"));
|
||||
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "in the file's own tab, no copy beside it");
|
||||
assert!(deliver(&script, &mut server, id), "the server takes it (new layers are that client's to create)");
|
||||
assert_eq!(layers(server.document()), 2, "everyone gets the edit");
|
||||
assert_eq!(next.execute("collab.peers", &json!({})).unwrap()["status"], "live");
|
||||
// Only now the old copy goes, after this run has copied what it became.
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists(), "the crashed run's folder is gone");
|
||||
let mine = root.join(std::process::id().to_string());
|
||||
assert_eq!((copies(&mine, "figcraft"), copies(&mine, "online")), (1, 1), "covered by this run's own copy");
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_the_server_already_has_is_discarded() {
|
||||
let root = root("equal");
|
||||
let (mut s, script, mut server, me) = shared();
|
||||
// Sent and accepted, but the app died before it heard so.
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(deliver(&script, &mut server, me));
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
assert_eq!(copies(&left, "figcraft"), 1);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
assert_eq!((next.docs.len(), layers(&next.docs[1].doc)), (2, 2), "the file as the server has it");
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing to send");
|
||||
assert!(!next.docs[1].is_dirty());
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_cant_be_merged_opens_beside_the_file() {
|
||||
let root = root("conflict");
|
||||
let (mut s, script, mut server, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
// Meanwhile someone else changes the file on the server.
|
||||
let (other, welcome) = join(&mut server, None);
|
||||
let mut them = Client::join(welcome).unwrap();
|
||||
let mut doc = them.document().clone();
|
||||
let page = doc.pages[0];
|
||||
doc.create(page, figcraft_engine::doc::NodeKind::Rectangle, "Theirs", 50.0, 50.0, 5.0, 5.0).unwrap();
|
||||
let batch = them.edit(&doc).unwrap().unwrap();
|
||||
assert!(!server.receive(other, batch).iter().any(|o| matches!(o, Outgoing::To(_, ServerMsg::Reject { .. }))));
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
// The server's file is untouched; the copy is a tab of its own, named as what it is.
|
||||
let names: Vec<&str> = next.docs.iter().map(|d| d.doc.name.as_str()).collect();
|
||||
assert_eq!(names, ["Untitled", "Homepage", "Homepage (recovered copy)"]);
|
||||
assert_eq!(next.active, 1, "the file stays in front");
|
||||
assert_eq!(layers(&next.docs[1].doc), 2, "the rectangle and theirs");
|
||||
assert_eq!(layers(&next.docs[2].doc), 2, "the rectangle and the ellipse that never got there");
|
||||
assert!(next.docs[2].is_dirty() && next.docs[2].path.is_none());
|
||||
assert_eq!(
|
||||
next.message.take().as_deref(),
|
||||
Some("Homepage changed on the server before your last changes reached it: they are open beside it as “Homepage (recovered copy)”")
|
||||
);
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing is forced onto the server");
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1, "the recovered tab has this run's copy");
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_whose_server_cant_be_reached_opens_as_any_other() {
|
||||
let root = root("offline");
|
||||
let (mut s, script, _, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
r.tick(&next, 0.0);
|
||||
assert_eq!(copies(&left, "figcraft"), 1, "kept while the join is on its way");
|
||||
script.lock().unwrap().events.push(Event::Status(Status::Failed { reason: "Couldn't reach the server (timed out)".into() }));
|
||||
next.live_poll();
|
||||
let ds = next.docs.last().unwrap();
|
||||
assert_eq!((ds.doc.name.as_str(), layers(&ds.doc), ds.is_dirty(), ds.path.is_none()), ("Homepage", 2, true, true));
|
||||
assert_eq!(next.active, next.docs.len() - 1);
|
||||
assert!(next.message.take().unwrap_or_default().contains("your recovered copy is open"));
|
||||
assert_eq!(next.prefs.online_files.len(), 1, "the record stays: the file is still yours on the server");
|
||||
r.tick(&next, 1.0);
|
||||
assert!(!left.exists());
|
||||
assert_eq!(copies(&root.join(std::process::id().to_string()), "figcraft"), 1);
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_forgot_the_client_gets_no_layers_it_would_refuse() {
|
||||
let root = root("forgot");
|
||||
let (mut s, script, _, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
crash(&s, &root);
|
||||
// The server lost its session (restored from a backup): the same file, another session, so
|
||||
// the app joins as someone new and can't create the layers of who it was.
|
||||
let mut restored = Server::new(Session::new().doc().unwrap().clone(), "", Limits::default());
|
||||
let (mut next, script, r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut restored);
|
||||
assert_eq!(next.docs.last().map(|d| d.doc.name.as_str()), Some("Homepage (recovered copy)"));
|
||||
assert!(script.lock().unwrap().sent.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })));
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_its_record_the_copy_opens_as_any_other() {
|
||||
let root = root("fallback");
|
||||
let (s, _, _, _) = shared();
|
||||
crash(&s, &root);
|
||||
// The record was forgotten (or the preferences lost): the copy is what there is.
|
||||
let (mut next, script) = session();
|
||||
next.docs.clear();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
assert!(script.lock().unwrap().joined.is_empty());
|
||||
assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none()));
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_note_follows_its_copy_and_goes_when_the_file_leaves_its_session() {
|
||||
let root = root("note");
|
||||
let (mut s, _, _, _) = shared();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&s, true);
|
||||
let dir = root.join(std::process::id().to_string());
|
||||
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 1));
|
||||
// Left: the copy is an ordinary one from the next write on.
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!((copies(&dir, "figcraft"), copies(&dir, "online")), (1, 0));
|
||||
r.finish();
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
|
|
@ -10,6 +10,8 @@ use serde_json::{Value, json};
|
|||
|
||||
pub struct Desktop {
|
||||
clipboard: Option<arboard::Clipboard>,
|
||||
/// Where the preferences live ([`config_dir`]).
|
||||
dir: Option<PathBuf>,
|
||||
pub prefs: Option<Prefs>,
|
||||
/// The system's reduce-motion setting (read in the background at startup).
|
||||
reduce_motion: Arc<AtomicBool>,
|
||||
|
|
@ -31,10 +33,13 @@ pub fn config_dir() -> Option<PathBuf> {
|
|||
|
||||
impl Desktop {
|
||||
pub fn load(recovery: crate::recovery::Shared) -> Self {
|
||||
let state: Value = config_dir()
|
||||
.and_then(|d| std::fs::read_to_string(d.join("state.json")).ok())
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(json!({}));
|
||||
Self::load_from(config_dir(), recovery)
|
||||
}
|
||||
|
||||
/// With the preferences kept in `dir`.
|
||||
pub fn load_from(dir: Option<PathBuf>, recovery: crate::recovery::Shared) -> Self {
|
||||
let state: Value =
|
||||
dir.as_ref().and_then(|d| std::fs::read_to_string(d.join(STATE)).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
|
||||
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
|
||||
// Recent files lived next to the preferences before they moved into them.
|
||||
if p.recent_files.is_empty()
|
||||
|
|
@ -48,18 +53,18 @@ impl Desktop {
|
|||
let reduce_motion = Arc::new(AtomicBool::new(false));
|
||||
let flag = reduce_motion.clone();
|
||||
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
|
||||
Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion, language: system_language(), recovery }
|
||||
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
|
||||
}
|
||||
}
|
||||
|
||||
/// The system's preferred interface language as a tag ("es-ES", "es_ES.UTF-8"): Windows' language
|
||||
/// list, macOS's AppleLanguages, the locale variables elsewhere.
|
||||
/// The system's preferred interface languages, most preferred first, comma-separated
|
||||
/// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the
|
||||
/// locale variables elsewhere. The UI shows the first one it has a catalog for.
|
||||
fn system_language() -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::System::UserProfile::GlobalizationPreferences::Languages()
|
||||
.and_then(|l| l.GetAt(0))
|
||||
.map(|s| s.to_string())
|
||||
.map(|l| l.into_iter().map(|s| s.to_string()).collect::<Vec<_>>().join(","))
|
||||
.inspect_err(|e| log::info!("language: can't read the system languages: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
|
@ -67,11 +72,20 @@ fn system_language() -> Option<String> {
|
|||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().ok()?;
|
||||
String::from_utf8_lossy(&out.stdout).split(['(', ',', ')']).map(|s| s.trim().trim_matches('"').to_string()).find(|s| !s.is_empty())
|
||||
let list: Vec<String> = String::from_utf8_lossy(&out.stdout)
|
||||
.split(['(', ',', ')'])
|
||||
.map(|s| s.trim().trim_matches('"').to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
Some(list.join(","))
|
||||
}
|
||||
// gettext's `LANGUAGE` priority list ("fr:de"), then the locale ("fr_FR.UTF-8").
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
["LC_ALL", "LC_MESSAGES", "LANG"].iter().filter_map(|k| std::env::var(k).ok()).find(|v| !v.is_empty() && v != "C" && v != "POSIX")
|
||||
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty() && v != "C" && v != "POSIX");
|
||||
let locale = ["LC_ALL", "LC_MESSAGES", "LANG"].into_iter().find_map(var);
|
||||
let list: Vec<String> = var("LANGUAGE").map(|l| l.replace(':', ",")).into_iter().chain(locale).collect();
|
||||
(!list.is_empty()).then(|| list.join(","))
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
{
|
||||
|
|
@ -117,16 +131,41 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
|||
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
||||
}
|
||||
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
let Some(dir) = config_dir() else { return };
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let v = json!({"prefs": app.session.prefs});
|
||||
let tmp = dir.join("state.json.tmp");
|
||||
if std::fs::write(&tmp, v.to_string()).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, dir.join("state.json"));
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
|
||||
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
let tmp = dir.join("state.json.tmp");
|
||||
let written = std::fs::create_dir_all(dir)
|
||||
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
|
||||
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
|
||||
if let Err(e) = written {
|
||||
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
|
||||
/// file from a crash is narrowed too: the mode given at open only applies to new files).
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
|
||||
let mut f = o.open(path)?;
|
||||
#[cfg(unix)]
|
||||
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
|
||||
f.write_all(bytes)
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
|
||||
|
|
@ -162,6 +201,11 @@ impl Services for Desktop {
|
|||
fn open_url(&mut self, url: &str) {
|
||||
let _ = webbrowser::open(url);
|
||||
}
|
||||
fn save_prefs(&mut self, prefs: &Prefs) {
|
||||
if let Some(dir) = &self.dir {
|
||||
write_prefs(dir, prefs);
|
||||
}
|
||||
}
|
||||
fn emergency_save(&mut self, session: &Session) {
|
||||
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
|
||||
r.write(session, true);
|
||||
|
|
@ -170,6 +214,9 @@ impl Services for Desktop {
|
|||
fn reduce_motion(&self) -> bool {
|
||||
self.reduce_motion.load(Ordering::Relaxed)
|
||||
}
|
||||
fn gamepad_held(&mut self) -> u32 {
|
||||
crate::gamepad::held()
|
||||
}
|
||||
fn system_language(&self) -> Option<String> {
|
||||
self.language.clone()
|
||||
}
|
||||
|
|
@ -177,7 +224,11 @@ impl Services for Desktop {
|
|||
|
||||
/// The window icon from the bundled app icon (if present).
|
||||
pub fn window_icon() -> Option<egui::IconData> {
|
||||
// macOS wants Apple's padded grid; the other platforms the rounded tile edge to edge.
|
||||
#[cfg(target_os = "macos")]
|
||||
let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png");
|
||||
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
|
||||
let (w, h) = img.dimensions();
|
||||
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
//! The preferences are kept as plugins change, not only when the app quits: a plugin imported
|
||||
//! (then removed) is still there (gone) when the app is killed and starts again.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::FigApp;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::services::Desktop;
|
||||
|
||||
const ID: &str = "org.example.tiny";
|
||||
|
||||
fn root(name: &str) -> std::path::PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-prefs-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&r);
|
||||
std::fs::create_dir_all(&r).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
/// A plugin folder (manifest and module) in `root`.
|
||||
fn plugin(root: &std::path::Path) -> std::path::PathBuf {
|
||||
let dir = root.join("tiny");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "tiny.wasm", "permissions": {"access": "read"}});
|
||||
std::fs::write(dir.join("figcraft-plugin.json"), manifest.to_string()).unwrap();
|
||||
let wasm =
|
||||
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap();
|
||||
std::fs::write(dir.join("tiny.wasm"), wasm).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// The app as it starts with its preferences in `dir`.
|
||||
fn start(dir: &std::path::Path) -> FigApp {
|
||||
let svc = Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)));
|
||||
let mut s = Session::new();
|
||||
if let Some(prefs) = svc.prefs.clone() {
|
||||
s.prefs = prefs;
|
||||
}
|
||||
FigApp::new(s, Box::new(svc))
|
||||
}
|
||||
|
||||
/// Draw two frames (the first only sets the fonts up).
|
||||
fn frames(fig: &mut FigApp, ctx: &egui::Context) {
|
||||
for _ in 0..2 {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| fig.ui(ui)).textures_delta.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_are_kept_when_the_app_is_killed() {
|
||||
let dir = root("killed");
|
||||
let folder = plugin(&dir);
|
||||
let ctx = egui::Context::default();
|
||||
let mut fig = start(&dir);
|
||||
assert!(fig.run("plugin.import", json!({"path": folder.to_string_lossy()})).is_some());
|
||||
frames(&mut fig, &ctx);
|
||||
// Killed: no quit, so nothing saves on the way out.
|
||||
drop(fig);
|
||||
let mut fig = start(&dir);
|
||||
assert_eq!(fig.session.prefs.plugins.iter().map(|p| p.manifest.id.as_str()).collect::<Vec<_>>(), [ID], "the plugin is still installed");
|
||||
assert!(fig.run("plugin.remove", json!({"id": ID})).is_some());
|
||||
frames(&mut fig, &ctx);
|
||||
drop(fig);
|
||||
assert!(start(&dir).session.prefs.plugins.is_empty(), "the removal was kept too");
|
||||
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn preferences_are_readable_by_their_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = root("private");
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
// An older world-readable file and a leftover temporary one from a crash.
|
||||
for name in ["state.json", "state.json.tmp"] {
|
||||
std::fs::write(dir.join(name), "{}").unwrap();
|
||||
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
}
|
||||
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
FigCraft app icon (figcraft.svg, figcraft-small.svg and every file derived from them in this
|
||||
directory: figcraft-<size>.png, figcraft.ico, hicolor/).
|
||||
|
||||
Original artwork made for this project by the project owner: an engraving-style drawing, then
|
||||
vectorised. No third-party material. Licensed like the rest of FigCraft, under either of
|
||||
|
||||
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
|
||||
* MIT license (https://opensource.org/licenses/MIT)
|
||||
|
||||
at your option.
|
||||
|
||||
Regenerate the derived files with `cargo xtask icon`.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# FigCraft app icon
|
||||
|
||||
A **chameleon**: an engraving portrait in profile with a curled tail, in the Crafting Apps'
|
||||
owl-template framing (full-bleed field, head and body filling the tile, body running off the edges).
|
||||
|
||||
## Palette
|
||||
|
||||
Exactly three colours:
|
||||
|
||||
| Colour | Hex | Used for |
|
||||
|---|---|---|
|
||||
| Ink | `#0b0b0c` | line work and contours |
|
||||
| Paper | `#efe9dc` | the figure |
|
||||
| FigCraft green (app colour) | `#02da96` | the full-bleed field |
|
||||
|
||||
## Geometry
|
||||
|
||||
A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS
|
||||
renders pad it onto Apple's 824/1024 icon grid; Windows and Linux use the tile edge to edge, with
|
||||
its rounded corners, like the other Crafting Apps.
|
||||
|
||||
## Provenance
|
||||
|
||||
The owner's original engraving-style drawing (1500 px, keyed to the palette), supplied for
|
||||
FigCraft and vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source
|
||||
image is not kept in this repo. License: see `LICENSE.txt`.
|
||||
|
||||
## Files
|
||||
|
||||
- `figcraft.svg`: canonical master (traced at 1500 px, the source's size).
|
||||
- `figcraft-small.svg`: lighter trace (traced at 1024 px); also the hicolor scalable icon.
|
||||
- `figcraft-<size>.png` (16–1024): renders on the macOS grid; `packaging/macos/package.sh` builds
|
||||
the `.icns` from them.
|
||||
- `figcraft.ico`: Windows icon, 16–256 px, embedded in the `.exe` by `apps/figcraft/build.rs`.
|
||||
- `hicolor/<size>/apps/ai.storyteller.figcraft.png` (16–512) and `hicolor/scalable/...svg`:
|
||||
Linux icon theme; the 256 px one is also the runtime window icon off macOS.
|
||||
|
||||
## Regenerate
|
||||
|
||||
Replace `figcraft.svg` (and `figcraft-small.svg`), then run `cargo xtask icon`. It needs `resvg`
|
||||
(`cargo install resvg`).
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 678 B |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 825 B |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 453 KiB |
|
|
@ -0,0 +1,318 @@
|
|||
//! 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(2).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)
|
||||
}
|
||||
|
||||
/// A server from before join codes that manages a file's links as it always did: two connections,
|
||||
/// the first makes links, the second (asked for codes) can't read the request.
|
||||
fn old_server_with_links() -> (Endpoint, std::thread::JoinHandle<()>) {
|
||||
use figcraft_collab::message::{Admin, AdminReply, LinkInfo, NewLink};
|
||||
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 || {
|
||||
let mut made = 0;
|
||||
for stream in listener.incoming().take(2).flatten() {
|
||||
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
|
||||
let say = |ws: &mut tungstenite::WebSocket<std::net::TcpStream>, m: ServerMsg| {
|
||||
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&m).unwrap())));
|
||||
};
|
||||
while let Ok(Message::Binary(b)) = ws.read() {
|
||||
match message::decode::<ClientMsg>(&b, usize::MAX) {
|
||||
Ok(ClientMsg::OpenHosting(_)) => say(&mut ws, ServerMsg::Hosting(Box::default())),
|
||||
Ok(ClientMsg::Admin(Admin::CreateLink { file, access, label })) => {
|
||||
made += 1;
|
||||
let link = LinkInfo { id: format!("link{made}"), file, access, label, created: 0 };
|
||||
say(&mut ws, ServerMsg::Admin(Box::new(AdminReply::Link(NewLink { link, token: format!("TokenTokenTokenToken0{made}") }))));
|
||||
}
|
||||
_ => {
|
||||
say(&mut ws, ServerMsg::Refused { reason: "that message couldn't be read".into() });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
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}");
|
||||
thread.join().unwrap();
|
||||
// Sharing a file there: it makes the links (it always could) but no codes, without an error:
|
||||
// the app shows the links.
|
||||
let (server, thread) = old_server_with_links();
|
||||
let shared =
|
||||
manage::share(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &[], true, &limits, Duration::from_secs(5)).unwrap().unwrap();
|
||||
assert!(shared.edit.code.is_none() && shared.view.as_ref().is_some_and(|v| v.code.is_none()));
|
||||
assert!(shared.edit.link.contains("&file=0123456789abcdef&token=TokenTokenTokenToken01") && shared.edit.id == "link1");
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use std::thread::JoinHandle;
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Presence, ServerMsg};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, Published, Status};
|
||||
use tungstenite::protocol::frame::coding::CloseCode;
|
||||
|
|
@ -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};
|
||||
|
|
@ -34,11 +34,13 @@ use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
|||
/// What [`Guest::publish`] uploads, and where.
|
||||
pub struct Publish {
|
||||
pub server: Endpoint,
|
||||
/// The server's key.
|
||||
pub key: String,
|
||||
/// The server's key (`None`: publish as anyone may, on a server open to the public).
|
||||
pub key: Option<String>,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
pub doc: Arc<Document>,
|
||||
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
|
||||
pub share: bool,
|
||||
}
|
||||
|
||||
/// A joined (or joining) session. Dropping it leaves.
|
||||
|
|
@ -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) })
|
||||
}
|
||||
|
||||
|
|
@ -75,11 +94,25 @@ impl Guest {
|
|||
let me = tx.clone();
|
||||
let thread = std::thread::Builder::new().name("live-publish".into()).spawn(move || {
|
||||
inbox.push(Event::Status(Status::Connecting));
|
||||
let (cfg, published) = match upload(&job, cfg) {
|
||||
let (cfg, published, hosting) = match upload(&job, cfg) {
|
||||
Ok(done) => done,
|
||||
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
|
||||
};
|
||||
// Before anyone hears the file is there, so what others join with shows with it. The
|
||||
// file's first links stay its owner's (this app joins with one): others get their own,
|
||||
// which stopping takes away again.
|
||||
let shared = job.share.then(|| {
|
||||
let owner = published.owner.as_deref().unwrap_or_default();
|
||||
let made = manage::share(&job.server, job.key.as_deref(), &published.file, owner, &[], true, &cfg.limits, cfg.connect_timeout);
|
||||
made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))
|
||||
});
|
||||
if let Some(h) = hosting {
|
||||
inbox.push(Event::Hosting(h));
|
||||
}
|
||||
inbox.push(Event::Published(published));
|
||||
if let Some(shared) = shared {
|
||||
inbox.push(Event::Shared(shared));
|
||||
}
|
||||
// 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();
|
||||
|
|
@ -138,33 +171,44 @@ struct Conn {
|
|||
sock: TcpStream,
|
||||
}
|
||||
|
||||
/// Keep `doc` on `server` as a new file named `name`: its edit link, and where it is with its links.
|
||||
/// Keep `doc` on `server` as a new file named `name`, with the server's `key` (or, without one, as
|
||||
/// anyone may on a server open to the public): its edit link, where it is with its links (and its
|
||||
/// owner token without a key), and what the server says about its hosting (without a key).
|
||||
pub(crate) fn upload_file(
|
||||
server: &Endpoint,
|
||||
key: &str,
|
||||
key: Option<&str>,
|
||||
name: &str,
|
||||
doc: &Document,
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Result<(Invite, Published), String> {
|
||||
) -> Result<(Invite, Published, Option<HostInfo>), String> {
|
||||
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
|
||||
let replies = manage::request(server, key, vec![Admin::Publish { name: name.to_string(), snapshot }], limits, timeout)?;
|
||||
let (file, edit, view) = match replies.into_iter().next() {
|
||||
Some(AdminReply::Published { file, edit, view }) => (file, edit, view),
|
||||
let publish = vec![Admin::Publish { name: name.to_string(), snapshot }];
|
||||
let (replies, hosting) = match key {
|
||||
Some(key) => (manage::request(server, key, publish, limits, timeout)?, None),
|
||||
None => {
|
||||
let (info, replies) = manage::hosting(server, "", "", publish, limits, timeout)?;
|
||||
(replies, Some(info))
|
||||
}
|
||||
};
|
||||
let (file, edit, view, owner, expires) = match replies.into_iter().next() {
|
||||
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
|
||||
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
|
||||
Some(AdminReply::Failed { reason }) => return Err(reason),
|
||||
_ => return Err("the server didn't say where the file is".into()),
|
||||
};
|
||||
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
|
||||
let (edit, view) = (link(edit.token), link(view.token));
|
||||
let published = Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
|
||||
Ok((edit, published))
|
||||
let published =
|
||||
Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link(), owner, expires };
|
||||
Ok((edit, published, hosting))
|
||||
}
|
||||
|
||||
/// Upload the document; the join settings for the new file and its links.
|
||||
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
|
||||
let (edit, published) = upload_file(&job.server, &job.key, &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
|
||||
/// Upload the document; the join settings for the new file, its links and the server's hosting.
|
||||
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published, Option<HostInfo>), String> {
|
||||
let (edit, published, hosting) = upload_file(&job.server, job.key.as_deref(), &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
|
||||
cfg.invite = edit;
|
||||
Ok((cfg, published))
|
||||
Ok((cfg, published, hosting))
|
||||
}
|
||||
|
||||
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
|
||||
|
|
@ -177,7 +221,7 @@ fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
|
|||
}
|
||||
|
||||
fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<Cmd>) {
|
||||
let resume: Arc<Resuming> = Arc::default();
|
||||
let resume: Arc<Resuming> = Arc::new(std::sync::Mutex::new(cfg.resume));
|
||||
let mut attempt = 0u32;
|
||||
let mut was_live = false;
|
||||
let mut pending: Option<Presence> = None;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret};
|
|||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, PEOPLE};
|
||||
|
||||
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name};
|
||||
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
|
||||
use crate::inbox::Inbox;
|
||||
pub use crate::join::ENDED;
|
||||
use crate::net::NetLimits;
|
||||
|
|
@ -85,16 +85,16 @@ impl Host {
|
|||
token: String::new(),
|
||||
name,
|
||||
color: PEOPLE[0].to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
client_version: figcraft_collab::message::client_version(),
|
||||
resume: None,
|
||||
};
|
||||
let (me, outs) = server.join(hello).map_err(|r| io::Error::other(format!("{r:?}")))?;
|
||||
// The host's window syncs through its own client too: its welcome carries the document.
|
||||
for o in outs {
|
||||
if let Outgoing::To(id, w @ ServerMsg::Welcome { .. }) = o
|
||||
&& id == me
|
||||
{
|
||||
inbox.push(Event::Sync(w));
|
||||
match o {
|
||||
Outgoing::To(id, w @ ServerMsg::Welcome { .. }) if id == me => inbox.push(Event::Sync(w)),
|
||||
Outgoing::To(id, ref m @ ServerMsg::Signal { .. }) if id == me => to_local(&inbox, me, m),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -38,18 +41,23 @@ pub struct GuestConfig {
|
|||
pub max_backoff: Duration,
|
||||
/// Reconnect attempts before giving up.
|
||||
pub attempts: u32,
|
||||
/// Join as the client you were (an app that starts over after a crash): the first hello
|
||||
/// carries it, and a server that still knows the id gives it back.
|
||||
pub resume: Option<Resume>,
|
||||
}
|
||||
|
||||
impl GuestConfig {
|
||||
pub fn new(invite: Invite, name: String) -> Self {
|
||||
Self {
|
||||
invite,
|
||||
code: None,
|
||||
name,
|
||||
limits: NetLimits::default(),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
backoff: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(10),
|
||||
attempts: 12,
|
||||
resume: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,10 +75,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 +131,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) => {
|
||||
|
|
@ -207,8 +242,12 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
|
|||
inbox.push(Event::Sync(m));
|
||||
}
|
||||
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
|
||||
// Answers for managing and library connections: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) => {}
|
||||
// Checked again by the engine (the host is as untrusted as any peer).
|
||||
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, and to a join code: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) | ServerMsg::Resolved { .. } => {}
|
||||
}
|
||||
Taken::Continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ pub mod inbox;
|
|||
pub mod invite;
|
||||
mod join;
|
||||
pub mod limits;
|
||||
mod sessions;
|
||||
pub mod throttle;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
@ -51,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)]
|
||||
|
|
@ -62,4 +65,6 @@ mod loopback_tests;
|
|||
#[cfg(test)]
|
||||
mod session_tests;
|
||||
#[cfg(test)]
|
||||
mod sessions_tests;
|
||||
#[cfg(test)]
|
||||
mod tls_tests;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Transport limits, shared by the native transport and the browser client.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Transport limits (the collaboration core has its own: [`figcraft_collab::Limits`]).
|
||||
|
|
@ -27,6 +28,31 @@ pub struct NetLimits {
|
|||
pub queue: usize,
|
||||
/// How long a write may block.
|
||||
pub write_timeout: Duration,
|
||||
/// Most connections from one address at once (an IPv6 address counts by its /64; 0: no cap).
|
||||
pub max_per_address: usize,
|
||||
/// The reverse proxy in front of a host, whose `X-Forwarded-For` names the client: only
|
||||
/// connections from this address are believed (the header is anyone's to forge).
|
||||
pub trusted_proxy: Option<IpAddr>,
|
||||
/// Bytes queued for one client before it counts as stuck (one message always fits an empty
|
||||
/// queue), for one block of addresses (an IPv4 address, an IPv6 /48), and for every client
|
||||
/// together (a broadcast counted once; past it the biggest consumer is dropped).
|
||||
pub max_queued_bytes: usize,
|
||||
pub max_queued_block: usize,
|
||||
pub max_queued_total: usize,
|
||||
/// Bytes being read into messages (not whole yet) for one block, and for every connection:
|
||||
/// past them the biggest readers are dropped.
|
||||
pub max_reading_block: usize,
|
||||
pub max_reading_total: usize,
|
||||
/// Bytes of messages handed to the hub and not handled yet, all connections together: past it
|
||||
/// a connection is dropped as busy instead of piling more on.
|
||||
pub max_in_flight: usize,
|
||||
/// Largest message on a connection that only asks small things (browsing a plugin directory).
|
||||
pub max_request: usize,
|
||||
/// Connections only sessions (`Hello`) and the operator may take: one-shot requests leave them.
|
||||
pub reserve_for_sessions: usize,
|
||||
/// A one-shot request connection (hosting, a library, a plugin directory) with no traffic for
|
||||
/// this long is dropped.
|
||||
pub request_idle: Duration,
|
||||
}
|
||||
|
||||
impl Default for NetLimits {
|
||||
|
|
@ -43,6 +69,17 @@ impl Default for NetLimits {
|
|||
keepalive: Duration::from_secs(15),
|
||||
queue: 1024,
|
||||
write_timeout: Duration::from_secs(10),
|
||||
max_per_address: 0,
|
||||
trusted_proxy: None,
|
||||
max_queued_bytes: 160 << 20,
|
||||
max_queued_block: usize::MAX,
|
||||
max_queued_total: usize::MAX,
|
||||
max_reading_block: usize::MAX,
|
||||
max_reading_total: usize::MAX,
|
||||
max_in_flight: usize::MAX,
|
||||
max_request: 64 << 10,
|
||||
reserve_for_sessions: 0,
|
||||
request_idle: Duration::from_secs(45),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ use std::sync::Arc;
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq};
|
||||
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo};
|
||||
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, Resume, Role,
|
||||
SessionId, SessionInfo, Shared,
|
||||
};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
|
||||
use crate::host::{Host, HostConfig};
|
||||
|
|
@ -14,6 +18,7 @@ use crate::inbox::{Inbox, Waker};
|
|||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::manage;
|
||||
use crate::net::NetLimits;
|
||||
use crate::sessions::Sessions;
|
||||
use crate::token;
|
||||
|
||||
/// The port a hosted session tries first.
|
||||
|
|
@ -27,15 +32,23 @@ enum Active {
|
|||
Guest(Guest),
|
||||
}
|
||||
|
||||
/// Hosts or joins one session at a time.
|
||||
impl Active {
|
||||
/// Stop hosting, or leave.
|
||||
fn end(self) {
|
||||
match self {
|
||||
Active::Host(mut h) => h.stop(),
|
||||
Active::Guest(mut g) => g.leave(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosts and joins sessions, any number at once (one per open document).
|
||||
pub struct NetLink {
|
||||
wake: Waker,
|
||||
limits: NetLimits,
|
||||
active: Option<Active>,
|
||||
inbox: Option<Inbox>,
|
||||
sessions: Sessions<Active>,
|
||||
/// Tweaks for joining (tests shorten the reconnect pauses).
|
||||
guest: fn(GuestConfig) -> GuestConfig,
|
||||
/// Answers to library requests sent in the background (outlives sessions).
|
||||
/// Answers to requests sent in the background (outlives sessions).
|
||||
libraries: Inbox,
|
||||
/// Background library requests are out.
|
||||
fetching: Arc<AtomicBool>,
|
||||
|
|
@ -45,7 +58,7 @@ impl NetLink {
|
|||
/// `wake` is called (from network threads) whenever something arrives for the UI.
|
||||
pub fn new(wake: Waker) -> Self {
|
||||
let libraries = Inbox::new(wake.clone());
|
||||
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c, libraries, fetching: Arc::default() }
|
||||
Self { limits: NetLimits::default(), sessions: Sessions::new(wake), guest: |c| c, libraries, fetching: Arc::default() }
|
||||
}
|
||||
|
||||
/// With other transport limits.
|
||||
|
|
@ -60,20 +73,27 @@ 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
|
||||
}
|
||||
|
||||
/// The running host, if hosting.
|
||||
pub fn host_handle(&self) -> Option<&Host> {
|
||||
match &self.active {
|
||||
/// The running host of session `session`, if it is hosted here.
|
||||
pub fn host_handle(&self, session: SessionId) -> Option<&Host> {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) => Some(h),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Session `session` started (one that had the id ends).
|
||||
fn started(&mut self, session: SessionId, active: Active, inbox: Inbox) {
|
||||
if let Some(old) = self.sessions.insert(session, active, inbox) {
|
||||
old.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine's address on the local network: the interface the default route leaves by (no
|
||||
|
|
@ -94,15 +114,20 @@ pub fn lan_ip() -> Option<IpAddr> {
|
|||
}
|
||||
|
||||
impl LiveLink for NetLink {
|
||||
fn host(&mut self, o: HostOptions) -> Result<SessionInfo, String> {
|
||||
self.leave();
|
||||
fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {
|
||||
let ip = match o.bind {
|
||||
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
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()?);
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
// 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();
|
||||
let file_name = doc.name.clone();
|
||||
|
|
@ -131,48 +156,75 @@ impl LiveLink for NetLink {
|
|||
network: !ip.is_loopback(),
|
||||
access: Access::Edit,
|
||||
};
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(Active::Host(host));
|
||||
self.started(session, Active::Host(host), inbox);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
|
||||
let invite = Invite::parse(link)?;
|
||||
let cfg = self.guest_config(invite, name);
|
||||
self.leave();
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
|
||||
self.join_resuming(session, link, name, None)
|
||||
}
|
||||
|
||||
fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, resume: Option<Resume>) -> Result<(), String> {
|
||||
let (invite, code) = Invite::parse_join(link)?;
|
||||
let cfg = GuestConfig { resume, ..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.inbox = Some(inbox);
|
||||
self.active = Some(Active::Guest(guest));
|
||||
self.started(session, Active::Guest(guest), inbox);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish(&mut self, o: PublishOptions) -> Result<(), String> {
|
||||
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
// Without a key: as anyone may, when the server is open to the public (it says if not).
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
// Until the upload is done the invite names only the server.
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, &o.me);
|
||||
self.leave();
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
let job = Publish { server, key, name: o.name, doc: o.doc };
|
||||
let 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, share: o.share };
|
||||
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(Active::Guest(guest));
|
||||
self.started(session, Active::Guest(guest), inbox);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upload(&mut self, o: PublishOptions) -> Result<Published, String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
upload_file(&server, &key, &o.name, &o.doc, &self.limits, CONNECT).map(|(_, published)| published)
|
||||
upload(o, &self.limits)
|
||||
}
|
||||
|
||||
fn library(&mut self, link: &str, req: LibraryReq) -> Result<LibraryReply, String> {
|
||||
manage::library(link, req, &self.limits, CONNECT)
|
||||
}
|
||||
|
||||
fn plugins(&mut self, server: &str, req: PluginReq) -> Result<PluginReply, String> {
|
||||
plugins(server, req, &self.limits)
|
||||
}
|
||||
|
||||
fn hosting(&mut self, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
hosting(server, file, owner, req, &self.limits)
|
||||
}
|
||||
|
||||
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
|
||||
share(server, file, owner, revoke, create, &self.limits)
|
||||
}
|
||||
|
||||
fn background(&mut self, job: u64, req: NetRequest) -> bool {
|
||||
let (inbox, limits) = (self.libraries.clone(), self.limits);
|
||||
let (failed, failure) = (inbox.clone(), req.failure());
|
||||
let spawned = std::thread::Builder::new().name("server-request".into()).spawn(move || {
|
||||
let reply = match req {
|
||||
NetRequest::Plugins { server, req } => NetReply::Plugins(plugins(&server, req, &limits)),
|
||||
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
|
||||
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
|
||||
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
|
||||
NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(share(&server, &file, &owner, &revoke, create, &limits)),
|
||||
};
|
||||
inbox.push(Event::Net { job, reply: Box::new(reply) });
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
failed.push(Event::Net { job, reply: Box::new(failure(format!("can't start a thread for the request: {e}"))) });
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn library_background(&mut self, jobs: Vec<(String, LibraryReq)>) {
|
||||
if jobs.is_empty() || self.fetching.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
|
|
@ -191,40 +243,77 @@ impl LiveLink for NetLink {
|
|||
}
|
||||
}
|
||||
|
||||
fn leave(&mut self) {
|
||||
match self.active.take() {
|
||||
Some(Active::Host(mut h)) => h.stop(),
|
||||
Some(Active::Guest(mut g)) => g.leave(),
|
||||
None => {}
|
||||
fn leave(&mut self, session: SessionId) {
|
||||
if let Some(active) = self.sessions.remove(session) {
|
||||
active.end();
|
||||
}
|
||||
self.inbox = None;
|
||||
}
|
||||
|
||||
fn presence(&mut self, p: &Presence) {
|
||||
match &self.active {
|
||||
fn presence(&mut self, session: SessionId, p: &Presence) {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) => h.presence(p.clone()),
|
||||
Some(Active::Guest(g)) => g.presence(p.clone()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&mut self, msg: ClientMsg) {
|
||||
match &self.active {
|
||||
fn send(&mut self, session: SessionId, msg: ClientMsg) {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) => h.send(msg),
|
||||
Some(Active::Guest(g)) => g.send(msg),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
let mut out = self.libraries.drain();
|
||||
out.extend(self.inbox.as_ref().map(Inbox::drain).unwrap_or_default());
|
||||
fn poll(&mut self) -> Vec<(SessionId, Event)> {
|
||||
let mut out: Vec<(SessionId, Event)> = self.libraries.drain().into_iter().map(|e| (NO_SESSION, e)).collect();
|
||||
out.extend(self.sessions.poll());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `o.doc` on a server as a new file (see [`LiveLink::upload`]).
|
||||
fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
|
||||
}
|
||||
|
||||
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
|
||||
/// its owner token (see [`LiveLink::hosting`]). A new link comes back whole, in place of its token.
|
||||
fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &NetLimits) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
let (endpoint, _) = Endpoint::parse(server)?;
|
||||
let (info, replies) = manage::hosting(&endpoint, file, owner, req.into_iter().collect(), limits, CONNECT)?;
|
||||
let reply = replies.into_iter().next().map(|r| match r {
|
||||
AdminReply::Link(mut l) => {
|
||||
l.token = Invite { server: endpoint.clone(), file: l.link.file.clone(), token: l.token }.link();
|
||||
AdminReply::Link(l)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
Ok((info, reply))
|
||||
}
|
||||
|
||||
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
|
||||
/// the file's owner.
|
||||
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
|
||||
let (endpoint, query) = Endpoint::parse(server)?;
|
||||
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
|
||||
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
|
||||
}
|
||||
|
||||
/// One request to the plugin directory at `server` (its key in the address's query, if any).
|
||||
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
|
||||
let (server, query) = Endpoint::parse(server)?;
|
||||
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default();
|
||||
manage::plugins(&server, key.trim(), req, limits, CONNECT)
|
||||
}
|
||||
|
||||
impl Drop for NetLink {
|
||||
fn drop(&mut self) {
|
||||
self.leave();
|
||||
for id in self.sessions.ids() {
|
||||
self.leave(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream};
|
|||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Limits};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status};
|
||||
|
|
@ -384,7 +384,7 @@ fn oversized_messages_end_the_connection() {
|
|||
let mut raw = Raw::hello(addr, TOKEN);
|
||||
let Some(ServerMsg::Welcome { client_id, .. }) = raw.next() else { panic!("no welcome") };
|
||||
raw.send(&ClientMsg::Presence(Presence { selection: Some((0..5000).map(NodeId).collect()), ..Presence::default() }));
|
||||
assert_eq!(raw.refusal().as_deref(), Some("message too large"));
|
||||
assert!(raw.refusal().is_some_and(|r| r.starts_with("message too large")));
|
||||
hp.wait("it left", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == client_id)));
|
||||
// The host still serves others.
|
||||
let (_a, mut ap) = join(&invite, "Ada");
|
||||
|
|
@ -557,3 +557,57 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
|
|||
assert!(!hp.left().contains(&ada));
|
||||
host.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signals_reach_only_clients_that_read_them_and_are_rate_limited() {
|
||||
let (mut host, mut hp, invite) = start();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
hp.wait("the host's own ready", |e| e.iter().any(|e| matches!(e, Event::Signal(_, s) if s.kind == Signal::READY)));
|
||||
// A client from before signals: its hello doesn't announce them.
|
||||
let mut old = Raw::connect(addr);
|
||||
let ClientMsg::Hello(h) = Client::hello(FILE, TOKEN, "Old", "") else { panic!("not a hello") };
|
||||
assert!(old.send(&ClientMsg::Hello(Hello { client_version: "0.3.1".into(), ..h })));
|
||||
assert!(matches!(old.next(), Some(ServerMsg::Welcome { .. })));
|
||||
let mut new = Raw::hello(addr, TOKEN);
|
||||
let Some(ServerMsg::Welcome { client_id: me, .. }) = new.next() else { panic!("no welcome") };
|
||||
assert_eq!(new.next(), Some(ServerMsg::Signal { client_id: me, signal: Signal::ready() }));
|
||||
// The host chats, then moves its pointer: the old client gets the pointer only.
|
||||
host.send(ClientMsg::Signal(Signal::chat("hi")));
|
||||
host.presence(presence(1.0, 2.0));
|
||||
let until_presence = |r: &mut Raw| {
|
||||
let mut got = Vec::new();
|
||||
loop {
|
||||
match r.next() {
|
||||
Some(ServerMsg::Presence { .. }) | None => return got,
|
||||
Some(m) => got.push(m),
|
||||
}
|
||||
}
|
||||
};
|
||||
let old_got = until_presence(&mut old);
|
||||
assert!(!old_got.iter().any(|m| matches!(m, ServerMsg::Signal { .. })), "{old_got:?}");
|
||||
let new_got = until_presence(&mut new);
|
||||
let hana = host.me().client_id;
|
||||
assert!(new_got.contains(&ServerMsg::Signal { client_id: hana, signal: Signal::chat("hi") }), "{new_got:?}");
|
||||
// A flood of signals: the excess is dropped (the connection stays), the host gets them cleaned.
|
||||
for i in 0..200 {
|
||||
assert!(new.send(&ClientMsg::Signal(Signal::chat(&format!("{i}\u{7}")))));
|
||||
}
|
||||
assert!(new.send(&ClientMsg::Presence(presence(3.0, 4.0))));
|
||||
hp.wait("the flood's end", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == me)));
|
||||
let chats: Vec<&Signal> = hp.events.iter().filter_map(|e| if let Event::Signal(id, s) = e { (*id == me).then_some(s) } else { None }).collect();
|
||||
assert!(chats.len() >= 40 && chats.len() < 200, "{} of 200 got through", chats.len());
|
||||
assert!(chats.iter().all(|s| s.kind == Signal::CHAT && s.text.ends_with(' ')), "cleaned on the way");
|
||||
assert!(new.send(&ClientMsg::Ping));
|
||||
assert_eq!(until_presence_or_pong(&mut new), Some(ServerMsg::Pong), "still connected");
|
||||
host.stop();
|
||||
}
|
||||
|
||||
/// The next message that isn't someone's presence or signal.
|
||||
fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
|
||||
loop {
|
||||
match r.next() {
|
||||
Some(ServerMsg::Presence { .. } | ServerMsg::Signal { .. }) => {}
|
||||
m => return m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
//! Requests to a server outside a live session, each on a connection of its own answered in
|
||||
//! order: managing its files with the server's key ([`Manage`], [`request`]; the app publishes
|
||||
//! through it, and the server's command line uses it to reach a running server), and using a
|
||||
//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]).
|
||||
//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]), and its
|
||||
//! plugin directory ([`OpenPlugins`], [`plugins`]: anyone browses, the key publishes).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, PROTOCOL, ServerMsg};
|
||||
use figcraft_collab::message::{
|
||||
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
|
||||
PluginReply, PluginReq, Resolve, ServerMsg,
|
||||
};
|
||||
use figcraft_engine::live::{Shared, SharedLink};
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
|
|
@ -37,6 +42,113 @@ pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetL
|
|||
Ok(replies)
|
||||
}
|
||||
|
||||
/// Ask the server at `server` about its hosting, with a file's owner token when `file` and `owner`
|
||||
/// aren't empty; what it says, and its replies to `requests` (publishing without the key, managing
|
||||
/// that file), in order. Fails when the server can't be reached or refuses (a wrong owner token, a
|
||||
/// blocked address); a server older than open hosting can't read the request, and is reported as
|
||||
/// taking files only with its key. Never echoes the token.
|
||||
pub fn hosting(
|
||||
server: &Endpoint,
|
||||
file: &str,
|
||||
owner: &str,
|
||||
requests: Vec<Admin>,
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Result<(HostInfo, Vec<AdminReply>), String> {
|
||||
let admin = |m: ServerMsg| match m {
|
||||
ServerMsg::Admin(r) => Some(*r),
|
||||
_ => None,
|
||||
};
|
||||
let open = OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), file: file.to_string(), owner: owner.to_string() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::OpenHosting(open))?;
|
||||
let info = match reply(&mut ws, limits, |m| match m {
|
||||
ServerMsg::Hosting(i) => Some(*i),
|
||||
_ => None,
|
||||
}) {
|
||||
Ok(i) => i,
|
||||
Err(e) if e.contains("couldn't be read") => {
|
||||
return Err(format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display()));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let mut replies = Vec::with_capacity(requests.len());
|
||||
for r in requests {
|
||||
send(&mut ws, &ClientMsg::Admin(r))?;
|
||||
replies.push(reply(&mut ws, limits, admin)?);
|
||||
}
|
||||
close(ws);
|
||||
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)
|
||||
}
|
||||
|
||||
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
|
||||
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
|
||||
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
|
||||
/// make a new edit link and a new view link and ask for their join codes: what others join with.
|
||||
/// A server older than join codes makes the links without codes.
|
||||
pub fn share(
|
||||
server: &Endpoint,
|
||||
key: Option<&str>,
|
||||
file: &str,
|
||||
owner: &str,
|
||||
revoke: &[String],
|
||||
create: bool,
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<Shared>, String> {
|
||||
let ask = |requests: Vec<Admin>| match key {
|
||||
Some(key) => request(server, key, requests, limits, timeout),
|
||||
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
|
||||
};
|
||||
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
|
||||
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
|
||||
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
|
||||
let mut made = Vec::new();
|
||||
for reply in ask(requests)? {
|
||||
match reply {
|
||||
AdminReply::Link(l) => made.push(l),
|
||||
// A link that is gone already is as revoked as it gets.
|
||||
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
|
||||
AdminReply::Failed { reason } => return Err(reason),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
|
||||
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
|
||||
};
|
||||
// Their codes, on a connection of its own: a server from before join codes can't read the
|
||||
// request and closes it (the links are shared without codes then).
|
||||
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
|
||||
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
|
||||
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
|
||||
let mut shared = |l: NewLink| SharedLink {
|
||||
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
|
||||
id: l.link.id,
|
||||
code: codes.next().flatten(),
|
||||
};
|
||||
let edit = shared(edit);
|
||||
Ok(Some(Shared { edit, view: Some(shared(view)) }))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -59,6 +171,31 @@ pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duratio
|
|||
Ok(r)
|
||||
}
|
||||
|
||||
/// Send one request to the plugin directory of the server at `server`, with its `key` (empty: only
|
||||
/// browsing); the reply. Fails when the server can't be reached, refuses the key or has no
|
||||
/// directory (an older server); a request it can't do is a [`PluginReply::Failed`] reply. Never
|
||||
/// echoes the key.
|
||||
pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits, timeout: Duration) -> Result<PluginReply, String> {
|
||||
let pick = |m: ServerMsg| match m {
|
||||
ServerMsg::Plugins(r) => Some(*r),
|
||||
_ => None,
|
||||
};
|
||||
let open = OpenPlugins { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::OpenPlugins(open))?;
|
||||
match reply(&mut ws, limits, pick) {
|
||||
Ok(PluginReply::Ready { .. }) => {}
|
||||
// An older server can't read the request at all.
|
||||
Err(e) if e.contains("couldn't be read") => return Err(format!("{} has no plugin directory (update the server)", server.display())),
|
||||
Err(e) => return Err(e),
|
||||
Ok(other) => return Err(format!("the server answered {other:?} instead of opening its plugin directory")),
|
||||
}
|
||||
send(&mut ws, &ClientMsg::Plugins(req))?;
|
||||
let r = reply(&mut ws, limits, pick)?;
|
||||
close(ws);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
|
||||
/// server a while to keep).
|
||||
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
|
||||
|
|
@ -80,12 +217,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})")),
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@
|
|||
//! machines over the same socket; every write goes through one lock and writes whole frames, so
|
||||
//! frames from the two never interleave.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{Shutdown, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -29,16 +31,150 @@ pub struct Duplex {
|
|||
read: TcpStream,
|
||||
write: Arc<Mutex<TcpStream>>,
|
||||
tls: Option<Tls>,
|
||||
/// What the bytes read count against (a server's reader), if anything.
|
||||
meter: Option<Meter>,
|
||||
}
|
||||
|
||||
impl Duplex {
|
||||
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
|
||||
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
|
||||
let write = Arc::new(Mutex::new(stream.try_clone()?));
|
||||
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone() };
|
||||
let b = Duplex { read: stream.try_clone()?, write, tls };
|
||||
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None };
|
||||
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
|
||||
Ok((a, b, stream))
|
||||
}
|
||||
|
||||
/// Count what this handle reads against `meter`.
|
||||
pub fn meter(&mut self, meter: Meter) {
|
||||
self.meter = Some(meter);
|
||||
}
|
||||
|
||||
/// What was read so far became a message (handed on): it no longer counts as being read.
|
||||
pub fn read_done(&mut self) {
|
||||
if let Some(m) = self.meter.as_mut() {
|
||||
m.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every reader that counts what it reads, with what it holds half-read and its socket: when
|
||||
/// everyone's reading budget runs out, the reader holding the most is cut off.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Readers(Arc<Mutex<HashMap<u64, Reader>>>);
|
||||
|
||||
/// One registered reader: what it holds half-read, its block's count, whether it was cut off, and
|
||||
/// its socket.
|
||||
struct Reader {
|
||||
mine: Arc<AtomicUsize>,
|
||||
block: Arc<AtomicUsize>,
|
||||
shed: Arc<AtomicBool>,
|
||||
sock: TcpStream,
|
||||
}
|
||||
|
||||
impl Readers {
|
||||
/// Cut off the reader holding the most half-read bytes, unless that is `me` (then `false`: the
|
||||
/// caller is the biggest). Its bytes are given back to the budget at once, it can't read any
|
||||
/// more, and its socket is shut (where that wakes a blocked read, it ends now; elsewhere at its
|
||||
/// read timeout).
|
||||
fn shed_biggest(&self, me: u64, all: &AtomicUsize) -> bool {
|
||||
let t = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let Some((&id, r)) = t.iter().filter(|(_, r)| !r.shed.load(Ordering::Relaxed)).max_by_key(|(_, r)| r.mine.load(Ordering::Relaxed)) else {
|
||||
return false;
|
||||
};
|
||||
if id == me || r.mine.load(Ordering::Relaxed) == 0 {
|
||||
return false;
|
||||
}
|
||||
log::info!("event=shed conn={id} reason=reading_bytes");
|
||||
r.shed.store(true, Ordering::Relaxed);
|
||||
let held = r.mine.swap(0, Ordering::Relaxed);
|
||||
r.block.fetch_sub(held, Ordering::Relaxed);
|
||||
all.fetch_sub(held, Ordering::Relaxed);
|
||||
let _ = r.sock.shutdown(Shutdown::Both);
|
||||
true
|
||||
}
|
||||
|
||||
/// Readers registered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).len()
|
||||
}
|
||||
|
||||
/// None registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes being read into messages (not whole yet), per connection, per block of addresses and
|
||||
/// for every connection together. A read that takes its block over its share fails. One that takes
|
||||
/// everyone over the budget cuts off the readers holding the most until it fits (their bytes are
|
||||
/// given back at once, they can't read any more and their sockets are shut) or, when this reader is
|
||||
/// the biggest, fails: the biggest readers go, not the next one.
|
||||
pub struct Meter {
|
||||
id: u64,
|
||||
mine: Arc<AtomicUsize>,
|
||||
shed: Arc<AtomicBool>,
|
||||
block: Arc<AtomicUsize>,
|
||||
all: Arc<AtomicUsize>,
|
||||
max_block: usize,
|
||||
max_all: usize,
|
||||
readers: Readers,
|
||||
}
|
||||
|
||||
/// What a reader cut off for using too much of the budget is told.
|
||||
const BUSY_READING: &str = "the server is busy reading from others: try again in a moment";
|
||||
|
||||
impl Meter {
|
||||
/// Connection `id`'s meter (registered with `readers`, cut off through `sock`).
|
||||
pub fn new(
|
||||
id: u64,
|
||||
sock: &TcpStream,
|
||||
readers: &Readers,
|
||||
block: Arc<AtomicUsize>,
|
||||
all: Arc<AtomicUsize>,
|
||||
max_block: usize,
|
||||
max_all: usize,
|
||||
) -> Self {
|
||||
let (mine, shed) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false)));
|
||||
if let Ok(s) = sock.try_clone() {
|
||||
let r = Reader { mine: mine.clone(), block: block.clone(), shed: shed.clone(), sock: s };
|
||||
readers.0.lock().unwrap_or_else(PoisonError::into_inner).insert(id, r);
|
||||
}
|
||||
Self { id, mine, shed, block, all, max_block, max_all, readers: readers.clone() }
|
||||
}
|
||||
|
||||
fn add(&mut self, n: usize) -> io::Result<()> {
|
||||
if self.shed.load(Ordering::Relaxed) {
|
||||
return Err(io::Error::other(BUSY_READING));
|
||||
}
|
||||
self.mine.fetch_add(n, Ordering::Relaxed);
|
||||
let block = self.block.fetch_add(n, Ordering::Relaxed).saturating_add(n);
|
||||
let mut all = self.all.fetch_add(n, Ordering::Relaxed).saturating_add(n);
|
||||
if block > self.max_block {
|
||||
return Err(io::Error::other(BUSY_READING));
|
||||
}
|
||||
// Over everyone's budget: the biggest readers are cut off until it fits, or this one goes
|
||||
// when it is the biggest.
|
||||
while all > self.max_all {
|
||||
if !self.readers.shed_biggest(self.id, &self.all) {
|
||||
return Err(io::Error::other(BUSY_READING));
|
||||
}
|
||||
all = self.all.load(Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
let mine = self.mine.swap(0, Ordering::Relaxed);
|
||||
self.block.fetch_sub(mine, Ordering::Relaxed);
|
||||
self.all.fetch_sub(mine, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Meter {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
self.readers.0.lock().unwrap_or_else(PoisonError::into_inner).remove(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Duplex {
|
||||
|
|
@ -51,10 +187,14 @@ impl Duplex {
|
|||
|
||||
impl Read for Duplex {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
match &self.tls {
|
||||
let n = match &self.tls {
|
||||
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
|
||||
None => self.read.read(buf),
|
||||
}?;
|
||||
if let Some(m) = self.meter.as_mut() {
|
||||
m.add(n)?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,13 +260,23 @@ impl Bucket {
|
|||
|
||||
/// Spend one token at `now`; false when there are none left.
|
||||
pub fn take(&mut self, now: Instant) -> bool {
|
||||
self.take_n(now, 1.0)
|
||||
}
|
||||
|
||||
/// Spend `n` tokens at `now`; false (spending none) when there aren't that many.
|
||||
pub fn take_n(&mut self, now: Instant, n: f64) -> bool {
|
||||
let dt = now.saturating_duration_since(self.at).as_secs_f64();
|
||||
self.at = now;
|
||||
self.tokens = (self.tokens + dt * self.rate).min(self.burst);
|
||||
if self.tokens < 1.0 {
|
||||
if self.tokens < n {
|
||||
return false;
|
||||
}
|
||||
self.tokens -= 1.0;
|
||||
self.tokens -= n;
|
||||
true
|
||||
}
|
||||
|
||||
/// Give back `n` tokens (up to the burst).
|
||||
pub fn give(&mut self, n: f64) {
|
||||
self.tokens = (self.tokens + n).min(self.burst);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
|
||||
//! broadcast counts once against the server's budget however many copies are queued, a block of
|
||||
//! addresses can't take more than its share, and when the server's budget is used up the
|
||||
//! connection holding the most is shed, not the next client that needs a little.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, sync_channel};
|
||||
|
||||
use tungstenite::Bytes;
|
||||
|
||||
use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
|
||||
|
||||
/// A line with its own queue counter, the given block counter and the server's.
|
||||
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
|
||||
let (tx, rx) = sync_channel(64);
|
||||
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
|
||||
}
|
||||
|
||||
fn socket() -> TcpStream {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
TcpStream::connect(l.local_addr().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broadcast_counts_once_against_the_server() {
|
||||
let all = Arc::new(AtomicUsize::new(0));
|
||||
let lines: Vec<_> = (0..4).map(|_| line(&Arc::default(), &all, usize::MAX, 10_000)).collect();
|
||||
let b = Bytes::from(vec![0u8; 3_000]);
|
||||
let mut held = Charges::default();
|
||||
for (l, _) in &lines {
|
||||
l.push(&b, &mut held, false).unwrap();
|
||||
}
|
||||
assert_eq!(all.load(Ordering::Relaxed), 3_000, "four copies of one message, counted once");
|
||||
assert!(lines.iter().all(|(l, _)| l.queued.load(Ordering::Relaxed) == 3_000), "each queue counts its copy");
|
||||
drop(held);
|
||||
// Sent (or dropped): the counts go back as the copies go.
|
||||
for (_, rx) in &lines {
|
||||
drop(rx.try_recv().unwrap());
|
||||
}
|
||||
assert_eq!(all.load(Ordering::Relaxed), 0);
|
||||
assert!(lines.iter().all(|(l, _)| l.queued.load(Ordering::Relaxed) == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_block_gets_only_its_share() {
|
||||
let (block, all) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
|
||||
let (a, _ra) = line(&block, &all, 1_000, usize::MAX);
|
||||
let (b, _rb) = line(&block, &all, 1_000, usize::MAX);
|
||||
let (c, _rc) = line(&Arc::default(), &all, 1_000, usize::MAX);
|
||||
let msg = Bytes::from(vec![0u8; 800]);
|
||||
a.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
assert_eq!(b.push(&msg, &mut Charges::default(), false), Err(Push::Full), "the block's second connection is over its share");
|
||||
c.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_biggest_consumer_is_shed_when_the_budget_runs_out() {
|
||||
let all = Arc::new(AtomicUsize::new(0));
|
||||
let ip: IpAddr = "203.0.113.1".parse().unwrap();
|
||||
let mut conns = HashMap::new();
|
||||
let mut receivers = Vec::new();
|
||||
for id in 1..=3u64 {
|
||||
let (l, rx) = line(&Arc::default(), &all, usize::MAX, 10_000);
|
||||
receivers.push(rx);
|
||||
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing, ip });
|
||||
}
|
||||
// Connection 1 doesn't read: it holds most of the budget.
|
||||
let big = Bytes::from(vec![0u8; 9_000]);
|
||||
assert!(deliver(&conns, 1, &big, &mut Charges::default(), &mut Vec::new()));
|
||||
// Connection 2 needs a little: the hog is shed and 2 still gets its message.
|
||||
let small = Bytes::from(vec![0u8; 2_000]);
|
||||
let mut shed = Vec::new();
|
||||
assert!(deliver(&conns, 2, &small, &mut Charges::default(), &mut shed));
|
||||
assert_eq!(shed, vec![1]);
|
||||
assert_eq!(conns[&2].out.queued.load(Ordering::Relaxed), 2_000);
|
||||
// When the receiver is itself the biggest, it is the one to go.
|
||||
let mut shed = Vec::new();
|
||||
assert!(!deliver(&conns, 1, &small, &mut Charges::default(), &mut shed), "the hog asking for more is dropped");
|
||||
assert!(shed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broadcast_counts_once_per_block_and_welcomes_not_at_all() {
|
||||
let (office, all) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
|
||||
// Twenty people behind one office's address, a block share of 3 MB.
|
||||
let lines: Vec<_> = (0..20).map(|_| line(&office, &all, 3_000_000, usize::MAX)).collect();
|
||||
// A 2 MB paste to all of them: one copy against the block, so everyone gets it.
|
||||
let paste = Bytes::from(vec![0u8; 2_000_000]);
|
||||
let mut charges = Charges::default();
|
||||
for (l, _) in &lines {
|
||||
l.push(&paste, &mut charges, false).unwrap();
|
||||
}
|
||||
assert_eq!(office.load(Ordering::Relaxed), 2_000_000);
|
||||
drop(charges);
|
||||
for (_, rx) in &lines {
|
||||
drop(rx.try_recv().unwrap());
|
||||
}
|
||||
assert_eq!(office.load(Ordering::Relaxed), 0);
|
||||
// Twenty welcomes of a large file at once (each its own message): not counted against the block.
|
||||
let welcome = Bytes::from(vec![0u8; 1_000_000]);
|
||||
for (l, _) in &lines {
|
||||
l.push(&welcome, &mut Charges { skip_blocks: true, ..Charges::default() }, false).unwrap();
|
||||
}
|
||||
assert_eq!(office.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(all.load(Ordering::Relaxed), 20_000_000, "they still count against the server's budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_nobody_uses_are_forgotten() {
|
||||
let blocks = Blocks::default();
|
||||
let a: IpAddr = "203.0.113.1".parse().unwrap();
|
||||
let held: BlockUse = blocks.get(a);
|
||||
let _b = blocks.get("203.0.113.2".parse().unwrap());
|
||||
assert_eq!(blocks.len(), 2);
|
||||
drop(held);
|
||||
// The next connection prunes the block whose connections are all gone.
|
||||
let _c = blocks.get("203.0.113.3".parse().unwrap());
|
||||
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
|
||||
}
|
||||
|
|
@ -47,10 +47,10 @@ fn host_and_join() -> (Session, Session, u32, u32) {
|
|||
let r = guest.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
pump(&mut host, &mut guest, "the join", |h, g| {
|
||||
g.live.status == Status::Live && h.live.peers.len() == 1 && g.live.info.as_ref().is_some_and(|i| !i.color.is_empty())
|
||||
g.live().status == Status::Live && h.live().peers.len() == 1 && g.live().info.as_ref().is_some_and(|i| !i.color.is_empty())
|
||||
});
|
||||
let hana = host.live.info.as_ref().unwrap().me;
|
||||
let ada = guest.live.info.as_ref().unwrap().me;
|
||||
let hana = host.live().info.as_ref().unwrap().me;
|
||||
let ada = guest.live().info.as_ref().unwrap().me;
|
||||
(host, guest, hana, ada)
|
||||
}
|
||||
|
||||
|
|
@ -62,23 +62,23 @@ fn host_join_and_see_each_other() {
|
|||
assert_eq!(g["fileName"], "Untitled", "the host's file name");
|
||||
assert_eq!(g["name"], "Ada");
|
||||
assert_eq!(g["peers"][0]["name"], "Hana");
|
||||
assert_eq!(host.live.peers[&ada].name, "Ada");
|
||||
assert_eq!(host.live().peers[&ada].name, "Ada");
|
||||
// The pointer: the guest's cursor shows up in the host's state.
|
||||
guest.execute("collab.cursor", &json!({"x": 120, "y": 80})).unwrap();
|
||||
pump(&mut host, &mut guest, "the guest's cursor", |h, _| {
|
||||
h.live.presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
|
||||
h.live().presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
|
||||
});
|
||||
// A selection travels too.
|
||||
let r = host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
host.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
pump(&mut host, &mut guest, "the host's selection", |_, g| {
|
||||
g.live.presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
|
||||
g.live().presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
|
||||
});
|
||||
// Leaving: the host sees it, the guest is back to no session.
|
||||
assert_eq!(guest.execute("collab.leave", &json!({})).unwrap()["ended"], true);
|
||||
assert_eq!(guest.live.status, Status::Off);
|
||||
pump(&mut host, &mut guest, "the leave", |h, _| h.live.peers.is_empty() && h.live.presence.is_empty());
|
||||
assert_eq!(guest.live().status, Status::Off);
|
||||
pump(&mut host, &mut guest, "the leave", |h, _| h.live().peers.is_empty() && h.live().presence.is_empty());
|
||||
host.execute("collab.stop", &json!({})).unwrap();
|
||||
assert_eq!(host.live_summary()["status"], "off");
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ fn host_join_and_see_each_other() {
|
|||
fn following_a_viewport_until_you_move() {
|
||||
let (mut host, mut guest, hana, ada) = host_and_join();
|
||||
host.execute("collab.follow", &json!({"client": ada})).unwrap();
|
||||
assert_eq!(host.live.follow, Some(ada));
|
||||
assert_eq!(host.live().follow, Some(ada));
|
||||
// The guest pans and zooms: the host's view goes along.
|
||||
{
|
||||
let ds = guest.ds_mut().unwrap();
|
||||
|
|
@ -103,29 +103,29 @@ fn following_a_viewport_until_you_move() {
|
|||
// The host pans: following stops, and later moves of the guest don't move the host.
|
||||
host.ds_mut().unwrap().view.cx += 100.0;
|
||||
host.live_sync();
|
||||
assert_eq!(host.live.follow, None);
|
||||
assert_eq!(host.live().follow, None);
|
||||
guest.ds_mut().unwrap().view.cx = -9000.0;
|
||||
pump(&mut host, &mut guest, "the guest's new viewport at the host", |h, _| {
|
||||
h.live.presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
|
||||
h.live().presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
|
||||
});
|
||||
assert!((host.ds().unwrap().view.cx - 5100.0).abs() < 1e-6);
|
||||
// Escape stops following too; following someone who isn't there fails.
|
||||
guest.execute("collab.follow", &json!({"client": hana})).unwrap();
|
||||
guest.escape().unwrap();
|
||||
assert_eq!(guest.live.follow, None);
|
||||
assert_eq!(guest.live().follow, None);
|
||||
assert!(guest.execute("collab.follow", &json!({"client": 999})).is_err());
|
||||
// The followed peer leaving ends the follow.
|
||||
host.execute("collab.follow", &json!({"client": ada})).unwrap();
|
||||
guest.execute("collab.leave", &json!({})).unwrap();
|
||||
pump(&mut host, &mut guest, "the leave", |h, _| h.live.follow.is_none() && h.live.peers.is_empty());
|
||||
pump(&mut host, &mut guest, "the leave", |h, _| h.live().follow.is_none() && h.live().peers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_stopping_ends_the_session_for_guests() {
|
||||
let (mut host, mut guest, _, _) = host_and_join();
|
||||
host.execute("collab.stop", &json!({})).unwrap();
|
||||
pump(&mut host, &mut guest, "the end", |_, g| g.live.status == Status::Off);
|
||||
assert!(guest.live.peers.is_empty());
|
||||
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
|
||||
assert!(guest.live().peers.is_empty());
|
||||
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
|
||||
}
|
||||
|
||||
|
|
@ -138,8 +138,8 @@ fn a_bad_invite_is_reported() {
|
|||
let r: Value = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
||||
let link = r["link"].as_str().unwrap().replace("token=", "token=x");
|
||||
guest.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live.status, Status::Failed { reason } if reason.contains("token")));
|
||||
assert!(host.live.peers.is_empty());
|
||||
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live().status, Status::Failed { reason } if reason.contains("token")));
|
||||
assert!(host.live().peers.is_empty());
|
||||
}
|
||||
|
||||
/// The synced properties of two documents agree (nothing to send from one to the other).
|
||||
|
|
@ -175,7 +175,7 @@ fn edits_sync_between_apps() {
|
|||
}
|
||||
};
|
||||
all(&mut host, &mut a, &mut b, "everyone syncing", &|h, a, b| {
|
||||
h.live.syncing() && a.live.syncing() && b.live.syncing() && h.live.peers.len() == 2
|
||||
h.live().syncing() && a.live().syncing() && b.live().syncing() && h.live().peers.len() == 2
|
||||
});
|
||||
// The guests got the host's file as a new tab.
|
||||
assert_eq!(a.docs.len(), 2);
|
||||
|
|
@ -208,7 +208,44 @@ fn edits_sync_between_apps() {
|
|||
// And the server's: someone joining now gets its document in the welcome.
|
||||
let mut c = session("Cy");
|
||||
c.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live.syncing());
|
||||
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live().syncing());
|
||||
assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's");
|
||||
host.execute("collab.stop", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_chat_and_spotlight_between_apps() {
|
||||
let (mut host, mut guest, hana, ada) = host_and_join();
|
||||
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals);
|
||||
// The guest chats next to its cursor; the host sees it, cleaned, and then sees it close.
|
||||
guest.execute("collab.cursor", &json!({"x": 40, "y": 30})).unwrap();
|
||||
guest.execute("collab.cursorChat", &json!({"text": "over\there"})).unwrap();
|
||||
pump(&mut host, &mut guest, "the chat", |h, _| h.live().chats.get(&ada).is_some_and(|t| t == "over here"));
|
||||
guest.execute("collab.cursorChat", &json!({"text": ""})).unwrap();
|
||||
pump(&mut host, &mut guest, "the chat closing", |h, _| h.live().chats.is_empty());
|
||||
// The host spotlights: the guest follows its view, page included.
|
||||
let second = host.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap();
|
||||
pump(&mut host, &mut guest, "the new page at the guest", |_, g| g.doc().unwrap().pages.contains(&second));
|
||||
assert_eq!(host.execute("collab.spotlight", &json!({})).unwrap()["spotlighting"], true);
|
||||
{
|
||||
let ds = host.ds_mut().unwrap();
|
||||
ds.page = second;
|
||||
ds.view.cx = 777.0;
|
||||
ds.view.cy = -55.0;
|
||||
}
|
||||
pump(&mut host, &mut guest, "the guest following the spotlight", |_, g| {
|
||||
let ds = g.ds().unwrap();
|
||||
g.live().spotlight == Some(hana) && g.live().follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6
|
||||
});
|
||||
// A late joiner is told and follows too.
|
||||
let mut late = session("Lin");
|
||||
late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap();
|
||||
pump(&mut host, &mut late, "the late joiner following", |_, l| l.live().follow == Some(hana) && l.live().spotlight == Some(hana));
|
||||
// The guest moves: it stops following, the spotlight stays; the host stops it for everyone.
|
||||
guest.ds_mut().unwrap().view.cx += 300.0;
|
||||
guest.live_sync();
|
||||
assert_eq!((guest.live().follow, guest.live().spotlight), (None, Some(hana)));
|
||||
host.execute("collab.spotlight", &json!({"on": false})).unwrap();
|
||||
pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live().spotlight.is_none());
|
||||
pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live().spotlight.is_none() && l.live().follow.is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
//! The sessions a link carries at once (the engine has one per document): each under the id the
|
||||
//! engine gave it, with its own [`Inbox`], so what happens in one never reaches another and a
|
||||
//! session that ended can't reach the next. The desktop link and the browser link both keep
|
||||
//! theirs here.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_engine::live::{Event, SessionId};
|
||||
|
||||
use crate::inbox::{Inbox, Waker};
|
||||
|
||||
/// Running sessions of kind `T` (a host or a guest connection) by id.
|
||||
pub(crate) struct Sessions<T> {
|
||||
wake: Waker,
|
||||
running: BTreeMap<SessionId, (T, Inbox)>,
|
||||
}
|
||||
|
||||
impl<T> Sessions<T> {
|
||||
pub(crate) fn new(wake: Waker) -> Self {
|
||||
Self { wake, running: BTreeMap::new() }
|
||||
}
|
||||
|
||||
/// A mailbox for a session about to start.
|
||||
pub(crate) fn inbox(&self) -> Inbox {
|
||||
Inbox::new(self.wake.clone())
|
||||
}
|
||||
|
||||
/// Session `id` started, with the mailbox its threads fill. Returns the session that had
|
||||
/// the id, to be ended.
|
||||
pub(crate) fn insert(&mut self, id: SessionId, session: T, inbox: Inbox) -> Option<T> {
|
||||
self.running.insert(id, (session, inbox)).map(|(old, _)| old)
|
||||
}
|
||||
|
||||
/// Session `id` is over: it comes back to be ended, and its mailbox is dropped.
|
||||
pub(crate) fn remove(&mut self, id: SessionId) -> Option<T> {
|
||||
self.running.remove(&id).map(|(session, _)| session)
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, id: SessionId) -> Option<&T> {
|
||||
self.running.get(&id).map(|(session, _)| session)
|
||||
}
|
||||
|
||||
/// Every session's id, oldest first.
|
||||
pub(crate) fn ids(&self) -> Vec<SessionId> {
|
||||
self.running.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// What happened in each session since the last call, with its id.
|
||||
pub(crate) fn poll(&self) -> Vec<(SessionId, Event)> {
|
||||
self.running.iter().flat_map(|(id, (_, inbox))| inbox.drain().into_iter().map(|e| (*id, e))).collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
//! Sessions per document over the network: the link's session table, and two engine sessions in
|
||||
//! one process that host and join several files at once through `collab.*` commands. Each file's
|
||||
//! session has its own people, links, edits and presence; closing a file ends its session only.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::live::{Event, Live, Presence, Role, Status};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::NetLink;
|
||||
use crate::loopback_tests::quick;
|
||||
use crate::sessions::Sessions;
|
||||
|
||||
#[test]
|
||||
fn each_session_has_its_own_mailbox() {
|
||||
let woken = Arc::new(AtomicUsize::new(0));
|
||||
let wake = woken.clone();
|
||||
let mut table: Sessions<&str> = Sessions::new(Arc::new(move || {
|
||||
wake.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
let (a, b) = (table.inbox(), table.inbox());
|
||||
assert!(table.insert(1, "host", a.clone()).is_none());
|
||||
assert!(table.insert(2, "guest", b.clone()).is_none());
|
||||
a.push(Event::PeerLeft(7));
|
||||
b.push(Event::PeerLeft(9));
|
||||
b.presence(3, Presence::default());
|
||||
assert_eq!(woken.load(Ordering::SeqCst), 3, "every arrival wakes the UI");
|
||||
assert_eq!(table.poll(), vec![(1, Event::PeerLeft(7)), (2, Event::PeerLeft(9)), (2, Event::Presence(3, Presence::default()))]);
|
||||
assert!(table.poll().is_empty(), "taken once");
|
||||
// A session that ended: what its threads still send goes nowhere, the other goes on.
|
||||
assert_eq!(table.remove(1), Some("host"));
|
||||
a.push(Event::PeerLeft(8));
|
||||
b.push(Event::PeerLeft(10));
|
||||
assert_eq!(table.poll(), vec![(2, Event::PeerLeft(10))]);
|
||||
assert_eq!((table.ids(), table.get(1), table.get(2)), (vec![2], None, Some(&"guest")));
|
||||
// An id started again replaces what had it, mailbox included.
|
||||
assert_eq!(table.insert(2, "again", table.inbox()), Some("guest"));
|
||||
b.push(Event::PeerLeft(11));
|
||||
assert!(table.poll().is_empty());
|
||||
assert_eq!(table.remove(3), None);
|
||||
}
|
||||
|
||||
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 UIs 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()["sessions"].to_string()).collect();
|
||||
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}: {states:?}");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn link_of(v: &Value) -> String {
|
||||
v["link"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn uid_at(s: &Session, index: usize) -> u64 {
|
||||
s.docs[index].uid
|
||||
}
|
||||
|
||||
/// The session syncing through the host at `address` (a guest's).
|
||||
fn joined<'a>(s: &'a Session, address: &str) -> Option<&'a Live> {
|
||||
s.live.iter().find(|l| l.syncing() && l.info.as_ref().is_some_and(|i| i.role == Role::Guest && i.address == address))
|
||||
}
|
||||
|
||||
fn has(s: &Session, uid: u64, node: u64) -> bool {
|
||||
s.docs.iter().find(|d| d.uid == uid).is_some_and(|d| d.doc.contains(NodeId(node)))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> u64 {
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_files_are_hosted_and_joined_independently() {
|
||||
let mut host = session("Hana");
|
||||
let mut guest = session("Ada");
|
||||
// File A is hosted; a new file B isn't in a session until it is hosted too.
|
||||
let a = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
||||
host.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
let off = host.execute("collab.peers", &json!({})).unwrap();
|
||||
assert_eq!((&off["status"], &off["session"], &off["link"], &off["document"]), (&json!("off"), &Value::Null, &Value::Null, &Value::Null));
|
||||
assert_eq!(off["sessions"].as_array().unwrap().len(), 1, "the first file's session goes on: {off}");
|
||||
assert_eq!(off["sessions"][0]["document"], 0);
|
||||
let b = host.execute("collab.host", &json!({"port": 0})).unwrap();
|
||||
assert_ne!(a["address"], b["address"], "each file listens on its own port");
|
||||
assert_ne!(a["session"], b["session"]);
|
||||
assert_eq!(b["sessions"].as_array().unwrap().len(), 2);
|
||||
let (addr_a, addr_b) = (a["address"].as_str().unwrap(), b["address"].as_str().unwrap());
|
||||
let (ha, hb) = (uid_at(&host, 0), uid_at(&host, 1));
|
||||
// The guest joins both, one right after the other: two more tabs, each with its session.
|
||||
for l in [&a, &b] {
|
||||
assert_eq!(guest.execute("collab.join", &json!({"link": link_of(l)})).unwrap()["status"], "connecting");
|
||||
}
|
||||
pump(&mut [&mut host, &mut guest], "both joins", |s| {
|
||||
joined(&*s[1], addr_a).is_some() && joined(&*s[1], addr_b).is_some() && s[0].live_of(ha).peers.len() == 1 && s[0].live_of(hb).peers.len() == 1
|
||||
});
|
||||
assert_eq!(guest.docs.len(), 3, "the guest's own file and the two it joined");
|
||||
let (ga, gb) = (joined(&guest, addr_a).unwrap().doc_uid.unwrap(), joined(&guest, addr_b).unwrap().doc_uid.unwrap());
|
||||
assert_ne!(ga, gb);
|
||||
assert_eq!(guest.live_of(uid_at(&guest, 0)).status, Status::Off, "the file the joins were started from is in no session");
|
||||
assert_eq!(guest.docs.iter().find(|d| d.uid == gb).unwrap().doc.name, "Second");
|
||||
// `collab.peers` is the file in front's: switching files switches it.
|
||||
assert_eq!(host.live_summary()["link"], b["link"]);
|
||||
host.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(host.live_summary()["link"], a["link"]);
|
||||
assert_eq!(host.live_summary()["document"], 0);
|
||||
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
|
||||
assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front");
|
||||
let in_a = rect(&mut host);
|
||||
host.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
let in_b = rect(&mut host);
|
||||
host.execute("node.set", &json!({"ids": [in_b], "props": {"name": "Only in B"}})).unwrap();
|
||||
let name = |s: &Session, uid: u64, node: u64| s.docs.iter().find(|d| d.uid == uid).and_then(|d| d.doc.node(NodeId(node))).map(|n| n.name.clone());
|
||||
// The rename is its own batch, behind the one that creates the layer: wait for it too.
|
||||
pump(&mut [&mut host, &mut guest], "the edits at the guest", |s| has(&*s[1], ga, in_a) && name(&*s[1], gb, in_b).as_deref() == Some("Only in B"));
|
||||
assert_ne!(name(&guest, ga, in_b).as_deref(), Some("Only in B"), "B's edit never reaches A");
|
||||
assert_ne!(name(&host, ha, in_b).as_deref(), Some("Only in B"));
|
||||
// And back: the guest edits its copy of A in the background tab's session.
|
||||
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == ga).unwrap()})).unwrap();
|
||||
let from_guest = rect(&mut guest);
|
||||
guest.execute("node.set", &json!({"ids": [from_guest], "props": {"name": "From Ada"}})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the guest's edit at the host", |s| name(&*s[0], ha, from_guest).as_deref() == Some("From Ada"));
|
||||
assert_ne!(name(&host, hb, from_guest).as_deref(), Some("From Ada"));
|
||||
// Presence stays in its session: the guest points in A (in front there), not in B.
|
||||
let ada = |s: &Session, uid: u64| s.live_of(uid).peers.keys().next().copied().unwrap();
|
||||
guest.execute("collab.cursor", &json!({"x": 64, "y": 48})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "the guest's cursor in A", |s| {
|
||||
s[0].live_of(ha).presence.get(&ada(&*s[0], ha)).and_then(|p| p.cursor).is_some_and(|c| c.1 == 64.0 && c.2 == 48.0)
|
||||
});
|
||||
assert!(host.live_of(hb).presence.get(&ada(&host, hb)).is_none_or(|p| p.cursor.is_none()), "no cursor in the file that isn't in front");
|
||||
// The host closes B: its session ends for everyone, A's goes on.
|
||||
host.execute("file.close", &json!({"index": 1, "force": true})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live.iter().count() == 1);
|
||||
assert_eq!(guest.live_of(gb).status, Status::Off);
|
||||
assert_eq!(guest.docs.len(), 3, "the guest keeps its copy of B");
|
||||
assert_eq!((host.live_of(ha).status.clone(), host.live_of(ha).peers.len()), (Status::Live, 1));
|
||||
assert_eq!(guest.live_of(ga).status, Status::Live);
|
||||
let later = rect(&mut host);
|
||||
pump(&mut [&mut host, &mut guest], "an edit after B closed", |s| has(&*s[1], ga, later));
|
||||
assert!(!has(&guest, gb, later));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_one_file_while_joined_to_another() {
|
||||
let mut x = session("Hana");
|
||||
let mut y = session("Ada");
|
||||
let from_x = link_of(&x.execute("collab.host", &json!({"port": 0})).unwrap());
|
||||
let from_y = link_of(&y.execute("collab.host", &json!({"port": 0})).unwrap());
|
||||
let (xu, yu) = (uid_at(&x, 0), uid_at(&y, 0));
|
||||
// Each joins the other's file from the tab it hosts: the hosting goes on.
|
||||
y.execute("collab.join", &json!({"link": from_x})).unwrap();
|
||||
x.execute("collab.join", &json!({"link": from_y})).unwrap();
|
||||
pump(&mut [&mut x, &mut y], "both joined", |s| {
|
||||
s.iter().all(|s| s.docs.len() == 2 && s.live().syncing() && s.live().role() == Some(Role::Guest))
|
||||
&& s[0].live_of(xu).peers.len() == 1
|
||||
&& s[1].live_of(yu).peers.len() == 1
|
||||
});
|
||||
for (s, own) in [(&x, xu), (&y, yu)] {
|
||||
let p = s.live_summary();
|
||||
assert_eq!((&p["role"], &p["document"]), (&json!("guest"), &json!(1)), "the joined file is in front: {p}");
|
||||
assert_eq!(p["sessions"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(s.live_of(own).role(), Some(Role::Host));
|
||||
assert_eq!(s.live_of(own).status, Status::Live);
|
||||
}
|
||||
// Y edits X's file (its tab in front) and its own; each lands in its own file at X.
|
||||
let in_x = rect(&mut y);
|
||||
y.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
let in_y = rect(&mut y);
|
||||
y.execute("node.set", &json!({"ids": [in_y], "props": {"name": "Hosted by Ada"}})).unwrap();
|
||||
let named = |s: &Session, uid: u64| {
|
||||
s.docs.iter().find(|d| d.uid == uid).is_some_and(|d| d.doc.node(NodeId(in_y)).is_some_and(|n| n.name == "Hosted by Ada"))
|
||||
};
|
||||
let x_copy_of_y = x.live().doc_uid.unwrap();
|
||||
pump(&mut [&mut x, &mut y], "Y's edits at X", |s| has(&*s[0], xu, in_x) && named(&*s[0], x_copy_of_y));
|
||||
assert!(!named(&x, xu), "the edit of Y's file isn't in X's");
|
||||
// Y leaves X's file: Y still hosts its own, with X in it.
|
||||
y.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(y.execute("collab.leave", &json!({})).unwrap()["ended"], true);
|
||||
pump(&mut [&mut x, &mut y], "the leave", |s| s[0].live_of(xu).peers.is_empty());
|
||||
assert_eq!((y.live().status.clone(), y.live.iter().count()), (Status::Off, 1));
|
||||
assert_eq!((y.live_of(yu).status.clone(), y.live_of(yu).peers.len()), (Status::Live, 1));
|
||||
assert_eq!(x.live_of(x_copy_of_y).status, Status::Live);
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
//! socket).
|
||||
//!
|
||||
//! The page runs everything on one thread: each session's socket, callbacks and timer live in a
|
||||
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only the session's
|
||||
//! number and its [`Inbox`]. Whatever arrives wakes egui through the [`Waker`]; nothing polls.
|
||||
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only each session's
|
||||
//! number there and its [`Inbox`](crate::inbox::Inbox). It joins any number of sessions at once
|
||||
//! (one per open document). Whatever arrives wakes egui through the [`Waker`]; nothing polls.
|
||||
//!
|
||||
//! An `https://` page can't open an unencrypted `ws://` connection (browsers block mixed content)
|
||||
//! except to this machine; [`NetLink::join`] says so up front.
|
||||
|
|
@ -17,16 +18,17 @@ use std::collections::BTreeMap;
|
|||
use std::rc::Rc;
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, Presence};
|
||||
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionInfo};
|
||||
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionId, SessionInfo};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::driver::{Driver, Socket};
|
||||
use crate::inbox::{Inbox, Waker};
|
||||
use crate::inbox::Waker;
|
||||
use crate::invite::Invite;
|
||||
use crate::join::GuestConfig;
|
||||
use crate::sessions::Sessions;
|
||||
|
||||
/// Close code for leaving normally.
|
||||
const CLOSE_NORMAL: u16 = 1000;
|
||||
|
|
@ -137,22 +139,21 @@ fn secure_page() -> bool {
|
|||
web_sys::window().and_then(|w| w.location().protocol().ok()).is_some_and(|p| p == "https:")
|
||||
}
|
||||
|
||||
/// The browser's [`LiveLink`]: joins one session at a time; hosting needs the desktop app.
|
||||
/// The browser's [`LiveLink`]: joins sessions; hosting needs the desktop app.
|
||||
pub struct NetLink {
|
||||
wake: Waker,
|
||||
active: Option<u64>,
|
||||
inbox: Option<Inbox>,
|
||||
/// Each session's number in the thread-local table.
|
||||
sessions: Sessions<u64>,
|
||||
}
|
||||
|
||||
impl NetLink {
|
||||
/// `wake` is called whenever something arrives for the UI (egui's `request_repaint`).
|
||||
pub fn new(wake: Waker) -> Self {
|
||||
Self { wake, active: None, inbox: None }
|
||||
Self { sessions: Sessions::new(wake) }
|
||||
}
|
||||
|
||||
fn with(&self, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
|
||||
if let Some(id) = self.active {
|
||||
with_driver(id, f);
|
||||
fn with(&self, session: SessionId, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
|
||||
if let Some(id) = self.sessions.get(session) {
|
||||
with_driver(*id, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -162,51 +163,54 @@ impl LiveLink for NetLink {
|
|||
false
|
||||
}
|
||||
|
||||
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
fn host(&mut self, _: SessionId, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("hosting a live session needs the desktop app; in the browser you can join one".into())
|
||||
}
|
||||
|
||||
fn join(&mut self, link: &str, name: &str) -> Result<(), String> {
|
||||
let invite = Invite::parse(link)?;
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
|
||||
let (invite, code) = Invite::parse_join(link)?;
|
||||
if secure_page() && !invite.server.secure && !invite.loopback() {
|
||||
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// link (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or join from the desktop app".into());
|
||||
}
|
||||
self.leave();
|
||||
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
|
||||
let inbox = Inbox::new(self.wake.clone());
|
||||
let driver = Driver::start(BrowserSocket { id, ws: None }, GuestConfig::new(invite, name.to_string()), inbox.clone(), Instant::now());
|
||||
let inbox = self.sessions.inbox();
|
||||
let cfg = GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) };
|
||||
let driver = Driver::start(BrowserSocket { id, ws: None }, cfg, inbox.clone(), Instant::now());
|
||||
let timer = Closure::<dyn FnMut()>::new(move || with_driver(id, |d, now| d.tick(now)));
|
||||
let mut g = Guest { driver, timer, timeout: None };
|
||||
g.arm();
|
||||
if !g.driver.done() {
|
||||
GUESTS.with(|t| t.borrow_mut().insert(id, Rc::new(RefCell::new(g))));
|
||||
}
|
||||
self.inbox = Some(inbox);
|
||||
self.active = Some(id);
|
||||
if let Some(old) = self.sessions.insert(session, id, inbox) {
|
||||
with_driver(old, |d, now| d.leave(now));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leave(&mut self) {
|
||||
self.with(|d, now| d.leave(now));
|
||||
self.active = None;
|
||||
self.inbox = None;
|
||||
fn leave(&mut self, session: SessionId) {
|
||||
if let Some(id) = self.sessions.remove(session) {
|
||||
with_driver(id, |d, now| d.leave(now));
|
||||
}
|
||||
}
|
||||
|
||||
fn presence(&mut self, p: &Presence) {
|
||||
self.with(|d, now| d.presence(p.clone(), now));
|
||||
fn presence(&mut self, session: SessionId, p: &Presence) {
|
||||
self.with(session, |d, now| d.presence(p.clone(), now));
|
||||
}
|
||||
|
||||
fn send(&mut self, msg: ClientMsg) {
|
||||
self.with(|d, now| d.send(msg, now));
|
||||
fn send(&mut self, session: SessionId, msg: ClientMsg) {
|
||||
self.with(session, |d, now| d.send(msg, now));
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
self.inbox.as_ref().map(Inbox::drain).unwrap_or_default()
|
||||
fn poll(&mut self) -> Vec<(SessionId, Event)> {
|
||||
self.sessions.poll()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NetLink {
|
||||
fn drop(&mut self) {
|
||||
self.leave();
|
||||
for id in self.sessions.ids() {
|
||||
self.leave(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ macro_rules! node_props {
|
|||
($($variant:ident => $field:ident),* $(,)?) => {
|
||||
/// A node property: the tree position (`parent`, `position`) or one saved field of
|
||||
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
|
||||
/// replaced whole. New properties go at the end (the binary form numbers them).
|
||||
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
|
||||
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Prop {
|
||||
|
|
@ -178,6 +179,7 @@ node_props! {
|
|||
PluginData => plugin_data,
|
||||
Annotations => annotations,
|
||||
CodeLinks => code_links,
|
||||
DevResources => dev_resources,
|
||||
}
|
||||
|
||||
/// A document-level property. Lists of entries with ids (styles, comments, variable collections,
|
||||
|
|
|
|||
|
|
@ -77,14 +77,14 @@ impl Client {
|
|||
token: token.to_string(),
|
||||
name: name.to_string(),
|
||||
color: color.to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
client_version: crate::message::client_version(),
|
||||
resume: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start from the server's `Welcome` (which must carry a snapshot).
|
||||
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), peers, access, .. } = welcome else {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
|
||||
return Err(Error::Protocol("expected a welcome with the document".into()));
|
||||
};
|
||||
let mut confirmed = Replica::from_snapshot(&snapshot)?;
|
||||
|
|
@ -97,7 +97,9 @@ impl Client {
|
|||
confirmed,
|
||||
seq,
|
||||
pending: VecDeque::new(),
|
||||
next_batch: 1,
|
||||
// After the batches the server already processed for this id (a client that starts
|
||||
// over under the id it had, e.g. after its app crashed: lower ids would be ignored).
|
||||
next_batch: processed.last_batch.saturating_add(1),
|
||||
next_id,
|
||||
peers: peers.into_iter().map(|p| (p.client_id, p)).collect(),
|
||||
presence: BTreeMap::new(),
|
||||
|
|
@ -449,7 +451,11 @@ impl Client {
|
|||
self.presence.remove(&client_id);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
ServerMsg::Pong => Ok(Vec::new()),
|
||||
// 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 { .. } | 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 {
|
||||
|
|
@ -459,7 +465,9 @@ impl Client {
|
|||
self.rejoin(seq, catch_up, &processed, peers, recompute)
|
||||
}
|
||||
ServerMsg::Refused { reason } => Err(Error::Protocol(reason)),
|
||||
ServerMsg::Admin(_) | ServerMsg::Library(_) => Err(Error::Protocol("an answer meant for a managing or library connection".into())),
|
||||
ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => {
|
||||
Err(Error::Protocol("an answer meant for a managing, library or plugin directory connection".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@ use figcraft_doc::{Document, NodeId, NodeKind};
|
|||
|
||||
use crate::change::{Change, Json, Prop};
|
||||
use crate::message::{
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileUsage, Hello, LibraryReply, LibraryReq, LinkInfo, Manage,
|
||||
NewLink, OpenLibrary, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileRow, FileUsage, Hello, HostInfo, LibraryReply, LibraryReq,
|
||||
LinkInfo, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed,
|
||||
ReportInfo, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
};
|
||||
use crate::sim::Rng;
|
||||
use crate::{Error, Replica, diff};
|
||||
|
|
@ -60,11 +61,37 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
ClientMsg::Library(LibraryReq::Fetch { have: Some(3), usage: Some(Usage { file: "Checkout".into(), components: vec![(NodeId(7), 12)] }) }),
|
||||
ClientMsg::Library(LibraryReq::Publish { archive: vec![0x50, 0x4B, 3, 4] }),
|
||||
ClientMsg::Library(LibraryReq::Usage),
|
||||
ClientMsg::Signal(Signal::chat("Hi ☕")),
|
||||
ClientMsg::Signal(Signal::spotlight(true)),
|
||||
ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: String::new(), client_version: "0.3.1".into() }),
|
||||
ClientMsg::Plugins(PluginReq::List { query: "grid".into() }),
|
||||
ClientMsg::Plugins(PluginReq::Fetch { id: "org.example.p".into(), version: Some("1.0.0".into()) }),
|
||||
ClientMsg::Plugins(PluginReq::Publish { bundle: vec![b'F', b'C'] }),
|
||||
ClientMsg::Plugins(PluginReq::Unpublish { id: "org.example.p".into(), version: None }),
|
||||
ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: "0.3.2 hosting".into(), file: "f".into(), owner: "o".into() }),
|
||||
ClientMsg::Admin(Admin::Rename { file: "f".into(), name: "Café 2".into() }),
|
||||
ClientMsg::Admin(Admin::Listing),
|
||||
ClientMsg::Admin(Admin::Reports),
|
||||
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
|
||||
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
|
||||
ClientMsg::Admin(Admin::Blocked),
|
||||
ClientMsg::Report { reason: "Spam ☕".into() },
|
||||
];
|
||||
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
|
||||
let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 };
|
||||
let link = LinkInfo { id: "l".into(), file: "f".into(), access: Access::Edit, label: String::new(), created: 6 };
|
||||
let new_link = NewLink { link: link.clone(), token: "t".into() };
|
||||
let entry = PluginEntry {
|
||||
id: "org.example.p".into(),
|
||||
name: "Grille ☕".into(),
|
||||
version: "1.2.0".into(),
|
||||
access: "write".into(),
|
||||
commands: vec!["node.*".into()],
|
||||
bytes: 9,
|
||||
sha256: "ab".into(),
|
||||
versions: vec!["1.2.0".into(), "1.0.0".into()],
|
||||
..PluginEntry::default()
|
||||
};
|
||||
let server = vec![
|
||||
ServerMsg::Welcome {
|
||||
session: 77,
|
||||
|
|
@ -93,9 +120,9 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
ServerMsg::Pong,
|
||||
ServerMsg::Admin(Box::new(AdminReply::Ready)),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Published { file: file.clone(), edit: new_link.clone(), view: new_link.clone() })),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Files(vec![file.clone()]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Links(vec![link]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Link(new_link))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Link(new_link.clone()))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Done)),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Ready { access: Access::View })),
|
||||
|
|
@ -108,6 +135,48 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
|||
updated: 9,
|
||||
}]))),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Failed { reason: "no".into() })),
|
||||
ServerMsg::Signal { client_id: 4, signal: Signal::chat("Hi ☕") },
|
||||
ServerMsg::Signal { client_id: 4, signal: Signal::ready() },
|
||||
ServerMsg::Plugins(Box::new(PluginReply::Ready { publisher: true })),
|
||||
ServerMsg::Plugins(Box::new(PluginReply::List(vec![entry.clone()]))),
|
||||
ServerMsg::Plugins(Box::new(PluginReply::Bundle { entry: entry.clone(), bundle: vec![1, 2] })),
|
||||
ServerMsg::Plugins(Box::new(PluginReply::Published(entry))),
|
||||
ServerMsg::Plugins(Box::new(PluginReply::Removed(vec!["1.0.0".into()]))),
|
||||
ServerMsg::Plugins(Box::new(PluginReply::Failed { reason: "no".into() })),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Hosted {
|
||||
file: file.clone(),
|
||||
edit: new_link.clone(),
|
||||
view: new_link.clone(),
|
||||
owner: "o".into(),
|
||||
expires: Some(99),
|
||||
})),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Listing(vec![FileRow {
|
||||
info: file.clone(),
|
||||
opened: 7,
|
||||
expires: None,
|
||||
open: true,
|
||||
ip: "203.0.113.9".into(),
|
||||
reports: 2,
|
||||
}]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Reports(vec![ReportInfo {
|
||||
at: 8,
|
||||
file: "f".into(),
|
||||
name: "Café".into(),
|
||||
link: "l".into(),
|
||||
ip: "203.0.113.9".into(),
|
||||
reason: "Spam".into(),
|
||||
}]))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Blocked(vec!["203.0.113.9".into()]))),
|
||||
ServerMsg::Hosting(Box::new(HostInfo {
|
||||
open: true,
|
||||
operator: "abuse@example.com".into(),
|
||||
terms: "https://example.com/terms".into(),
|
||||
idle_secs: 86_400,
|
||||
max_file_mb: 16,
|
||||
expires: Some(1_800_000_000),
|
||||
})),
|
||||
ServerMsg::Reported { error: None },
|
||||
ServerMsg::Reported { error: Some("too many reports".into()) },
|
||||
];
|
||||
(client, server)
|
||||
}
|
||||
|
|
@ -127,6 +196,8 @@ fn every_message_round_trips_in_both_forms() {
|
|||
let json = encode_json(&moved_layer()).unwrap();
|
||||
assert!(json.starts_with(r#"{"changes":{"base_seq":1234,"batch_id":56,"changes":[{"set":"#), "{json}");
|
||||
assert!(json.contains(r#""prop":"transform","value":[1.0,0.0,0.0,1.0,164.5,92.25]"#), "{json}");
|
||||
let chat = encode_json(&ClientMsg::Signal(Signal::chat("hi"))).unwrap();
|
||||
assert_eq!(chat, r#"{"signal":{"kind":"chat","text":"hi"}}"#);
|
||||
// The binary form starts with the wire version and carries values as JSON text.
|
||||
let bin = encode(&moved_layer()).unwrap();
|
||||
assert_eq!(bin[0], WIRE_VERSION);
|
||||
|
|
|
|||
|
|
@ -109,7 +109,13 @@ fn kind_changes_and_transforms_round_trip() {
|
|||
}
|
||||
|
||||
fn style(id: &str) -> Style {
|
||||
Style { id: id.into(), name: format!("S {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] } }
|
||||
Style {
|
||||
id: id.into(),
|
||||
name: format!("S {id}"),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] },
|
||||
bound: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use crate::sim::Sim;
|
|||
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
fn style(id: String, name: &str) -> Style {
|
||||
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } }
|
||||
Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() }, bound: Default::default() }
|
||||
}
|
||||
|
||||
fn comment(id: u64, doc: &Document) -> Comment {
|
||||
|
|
@ -122,6 +122,7 @@ fn create_everything(doc: &mut Document, who: usize, round: usize) {
|
|||
hidden_from_publishing: false,
|
||||
extends: None,
|
||||
overrides: Default::default(),
|
||||
code_syntax: Default::default(),
|
||||
});
|
||||
let k = doc.new_comment_id();
|
||||
let c = comment(k, doc);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
pub mod change;
|
||||
pub mod client;
|
||||
pub mod code;
|
||||
mod diff;
|
||||
mod docprops;
|
||||
pub mod frac;
|
||||
|
|
@ -41,7 +42,7 @@ pub use client::{Client, HISTORY_LIMIT};
|
|||
pub use diff::{Diff, diff};
|
||||
pub use figcraft_doc::{Document, NodeId};
|
||||
pub use history::{BatchKind, Notice};
|
||||
pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg};
|
||||
pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg, Signal};
|
||||
pub use replica::{Applied, Mode, Replica};
|
||||
pub use server::{Journal, Outgoing, Server, SessionState, VIEW_ONLY};
|
||||
|
||||
|
|
@ -87,6 +88,8 @@ pub struct Limits {
|
|||
pub max_log_batches: usize,
|
||||
/// Most changes in those batches together.
|
||||
pub max_log_changes: usize,
|
||||
/// About how many bytes those batches may hold together ([`server::weight`]).
|
||||
pub max_log_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
|
|
@ -99,6 +102,7 @@ impl Default for Limits {
|
|||
max_name: 64,
|
||||
max_log_batches: 10_000,
|
||||
max_log_changes: 1_000_000,
|
||||
max_log_bytes: usize::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -130,12 +134,18 @@ mod frac_tests;
|
|||
#[cfg(test)]
|
||||
mod journal_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_list_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
#[cfg(test)]
|
||||
mod reconnect_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
#[cfg(test)]
|
||||
mod signal_tests;
|
||||
#[cfg(test)]
|
||||
mod sim_tests;
|
||||
#[cfg(test)]
|
||||
mod undo_tests;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
//!
|
||||
//! A conflict is the same property (or document-level entry) changed on both sides to different
|
||||
//! values, or a layer deleted on one side while the other side edited it or something inside it
|
||||
//! (including adding layers to it or moving layers into or out of it). Nothing that conflicts is
|
||||
//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects
|
||||
//! merge item by item: both sides changing different items of the list (or one adding items at its
|
||||
//! end) is no conflict, only the same item changed both ways or the list shortened on a side
|
||||
//! while the other changed it. Nothing that conflicts is
|
||||
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
|
||||
//! changes to it are left out. Everything else from both sides is kept.
|
||||
|
||||
|
|
@ -13,8 +16,9 @@ use std::fmt;
|
|||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Prop};
|
||||
use crate::change::{Change, DocProp, Entry, Json, Prop};
|
||||
use crate::replica::{Mode, Positions, Replica, create_change};
|
||||
use crate::{Error, diff};
|
||||
|
||||
|
|
@ -155,6 +159,55 @@ fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
|
|||
out
|
||||
}
|
||||
|
||||
/// List properties whose items merge one by one.
|
||||
const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects];
|
||||
|
||||
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
|
||||
/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict.
|
||||
fn merged_list(base: &Document, key: &Key, b: Option<&Change>, m: Option<&Change>) -> Option<Json> {
|
||||
let Key::Node(id, prop) = key else { return None };
|
||||
let (Some(Change::Set { value: bv, .. }), Some(Change::Set { value: mv, .. })) = (b, m) else { return None };
|
||||
if !PER_ITEM.contains(prop) {
|
||||
return None;
|
||||
}
|
||||
let o = prop.get(base.node(*id)?).ok()?;
|
||||
merge_items(o.0.as_array()?, bv.0.as_array()?, mv.0.as_array()?).map(|v| Json(Value::Array(v)))
|
||||
}
|
||||
|
||||
/// The change at the only index in `c`.
|
||||
fn only<'a>(c: &[usize], all: &'a [Change]) -> Option<&'a Change> {
|
||||
match c {
|
||||
[i] => all.get(*i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-way merge of list `o` changed to `b` on one side and `m` on the other. Items at the same
|
||||
/// index merge one by one (changed on one side: that side's; on both: only the same way), and
|
||||
/// items added after `o`'s on one side (or the same ones on both) are kept. `None` when the same
|
||||
/// item changed both ways, both added different items, or a side removed items (lists without ids
|
||||
/// can't be lined up then).
|
||||
pub(crate) fn merge_items(o: &[Value], b: &[Value], m: &[Value]) -> Option<Vec<Value>> {
|
||||
let (tb, tm) = (b.get(o.len()..)?, m.get(o.len()..)?);
|
||||
let mut out = Vec::with_capacity(b.len().max(m.len()));
|
||||
for (i, base) in o.iter().enumerate() {
|
||||
let (x, y) = (b.get(i)?, m.get(i)?);
|
||||
let item = if x == base {
|
||||
y
|
||||
} else if y == base || x == y {
|
||||
x
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
out.push(item.clone());
|
||||
}
|
||||
if !tb.is_empty() && !tm.is_empty() && tb != tm {
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(if tb.is_empty() { tm } else { tb });
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The changes (indexes) that edit what's in `gone`: write one of its layers, create a layer in it
|
||||
/// (or in a layer created in it) or move a layer into it. Changes come in [`diff`] order, so
|
||||
/// creations come parents first and before moves.
|
||||
|
|
@ -189,9 +242,10 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
|||
let base_r = Replica::new(base.clone());
|
||||
let b = diff(&base_r, branch)?;
|
||||
let m = diff(&base_r, main)?;
|
||||
let bc: Vec<Change> = b.changes.into_iter().filter(merges).collect();
|
||||
let mc: Vec<Change> = m.changes.into_iter().filter(merges).collect();
|
||||
let mut bc: Vec<Change> = b.changes.into_iter().filter(merges).collect();
|
||||
let mut mc: Vec<Change> = m.changes.into_iter().filter(merges).collect();
|
||||
let mut conflicts = Vec::new();
|
||||
let mut lists = Vec::new();
|
||||
let km = by_key(&mc);
|
||||
for (k, bi) in by_key(&bc) {
|
||||
// Deletions conflict with edits (below); images are content-addressed, so never differ.
|
||||
|
|
@ -203,8 +257,23 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
|||
// out once the plan can tell followed values from overrides.
|
||||
let Some(mi) = km.get(&k) else { continue };
|
||||
let same = bi.len() == mi.len() && bi.iter().zip(mi).all(|(x, y)| bc.get(*x) == mc.get(*y));
|
||||
if !same {
|
||||
conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() });
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
match merged_list(base, &k, only(&bi, &bc), only(mi, &mc)) {
|
||||
Some(value) => lists.push((bi, mi.clone(), value)),
|
||||
None => conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() }),
|
||||
}
|
||||
}
|
||||
// Both sides' writes of a list merged item by item become the merged list, so merging either
|
||||
// way writes it.
|
||||
for (bi, mi, value) in lists {
|
||||
for (changes, at) in [(&mut bc, bi), (&mut mc, mi)] {
|
||||
for i in at {
|
||||
if let Some(Change::Set { value: v, .. }) = changes.get_mut(i) {
|
||||
*v = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (side, changes, doc, other) in [(Side::Branch, &bc, branch, &mc), (Side::Main, &mc, main, &bc)] {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
//! Fills, strokes and effects merge item by item: different items changed on each side merge,
|
||||
//! the same item changed both ways conflicts.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::merge::{Key, Side, merge_items, plan};
|
||||
|
||||
/// A file with one rectangle (two fills, one stroke, two effects), a branch of it and main.
|
||||
fn files() -> (Document, Document, Document, NodeId) {
|
||||
let mut base = Document::new("File");
|
||||
let page = base.first_page().unwrap();
|
||||
let r = base.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let n = base.get_mut(r).unwrap();
|
||||
n.fills = vec![Paint::solid(Color::BLACK), Paint::solid(Color::WHITE)];
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.effects = vec![Effect::drop_shadow(), Effect::layer_blur()];
|
||||
let mut branch = base.clone();
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
let main = base.clone();
|
||||
(base, branch, main, r)
|
||||
}
|
||||
|
||||
fn red() -> Paint {
|
||||
Paint::solid(Color::from_rgb8_const(255, 0, 0))
|
||||
}
|
||||
|
||||
fn blue() -> Paint {
|
||||
Paint::solid(Color::from_rgb8_const(0, 0, 255))
|
||||
}
|
||||
|
||||
/// Merging either way gives `check` the same result.
|
||||
fn both_ways(base: &Document, branch: &Document, main: &Document, check: impl Fn(&Document)) {
|
||||
let p = plan(base, branch, main).unwrap();
|
||||
assert!(p.conflicts.is_empty(), "{:?}", p.conflicts);
|
||||
for (into, from) in [(main, Side::Branch), (branch, Side::Main)] {
|
||||
let (doc, _) = p.merge(into, from, &|_| None).unwrap();
|
||||
assert!(doc.validate().is_empty());
|
||||
check(&doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_items_of_a_list_merge() {
|
||||
// The branch recolours the first fill and hides the shadow; main recolours the second fill
|
||||
// and hides the blur.
|
||||
let (base, mut branch, mut main, r) = files();
|
||||
branch.get_mut(r).unwrap().fills[0] = red();
|
||||
branch.get_mut(r).unwrap().effects[0].visible = false;
|
||||
main.get_mut(r).unwrap().fills[1] = blue();
|
||||
main.get_mut(r).unwrap().effects[1].visible = false;
|
||||
both_ways(&base, &branch, &main, |doc| {
|
||||
let n = doc.node(r).unwrap();
|
||||
assert_eq!(n.fills, vec![red(), blue()]);
|
||||
assert!(!n.effects[0].visible && !n.effects[1].visible);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_added_on_one_side_are_kept() {
|
||||
// The branch adds a stroke; main recolours the existing one. The same item added on both
|
||||
// sides is kept once.
|
||||
let (base, mut branch, mut main, r) = files();
|
||||
branch.get_mut(r).unwrap().strokes.push(red());
|
||||
main.get_mut(r).unwrap().strokes[0] = blue();
|
||||
branch.get_mut(r).unwrap().fills.push(red());
|
||||
main.get_mut(r).unwrap().fills.push(red());
|
||||
both_ways(&base, &branch, &main, |doc| {
|
||||
let n = doc.node(r).unwrap();
|
||||
assert_eq!(n.strokes, vec![blue(), red()]);
|
||||
assert_eq!(n.fills.len(), 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_item_changed_both_ways_conflicts() {
|
||||
let (base, mut branch, mut main, r) = files();
|
||||
branch.get_mut(r).unwrap().fills[0] = red();
|
||||
main.get_mut(r).unwrap().fills[0] = blue();
|
||||
// A removed item on one side while the other changed the list conflicts too (items have no
|
||||
// ids to line them up by).
|
||||
branch.get_mut(r).unwrap().effects.remove(0);
|
||||
main.get_mut(r).unwrap().effects[1].visible = false;
|
||||
// Different items added at the end on both sides conflict.
|
||||
branch.get_mut(r).unwrap().strokes.push(red());
|
||||
main.get_mut(r).unwrap().strokes.push(blue());
|
||||
let p = plan(&base, &branch, &main).unwrap();
|
||||
let mut keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
keys.sort();
|
||||
let mut want = [format!("{r}/effects"), format!("{r}/fills"), format!("{r}/strokes")];
|
||||
want.sort();
|
||||
assert_eq!(keys, want);
|
||||
// Resolving takes that side's whole list.
|
||||
let pick = |s: Side| move |_: &Key| Some(s);
|
||||
let (doc, _) = p.merge(&main, Side::Branch, &pick(Side::Branch)).unwrap();
|
||||
assert_eq!(doc.node(r).unwrap().fills, branch.node(r).unwrap().fills);
|
||||
let (doc, _) = p.merge(&main, Side::Branch, &pick(Side::Main)).unwrap();
|
||||
assert_eq!(doc.node(r).unwrap().fills, main.node(r).unwrap().fills);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_lists_still_conflict_as_a_whole() {
|
||||
// Docs silent: lists other than fills, strokes and effects (export settings here) still
|
||||
// conflict whole when both sides change them.
|
||||
let (mut base, _, _, r) = files();
|
||||
let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() };
|
||||
base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")];
|
||||
let (mut branch, mut main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
branch.get_mut(r).unwrap().exports[0] = export("@b");
|
||||
main.get_mut(r).unwrap().exports[1] = export("@m");
|
||||
let p = plan(&base, &branch, &main).unwrap();
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, [format!("{r}/exports")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_merge_rules() {
|
||||
let v = |x: Value| x.as_array().unwrap().clone();
|
||||
let (o, b, m) = (v(json!([1, 2, 3])), v(json!([9, 2, 3])), v(json!([1, 2, 8])));
|
||||
assert_eq!(merge_items(&o, &b, &m), Some(v(json!([9, 2, 8]))));
|
||||
assert_eq!(merge_items(&o, &b, &b), Some(b.clone()), "the same change on both sides");
|
||||
assert_eq!(merge_items(&o, &b, &v(json!([7, 2, 3]))), None, "the same item both ways");
|
||||
assert_eq!(merge_items(&o, &v(json!([1, 2])), &m), None, "an item removed");
|
||||
assert_eq!(merge_items(&o, &v(json!([1, 2, 3, 4])), &m), Some(v(json!([1, 2, 8, 4]))));
|
||||
assert_eq!(merge_items(&o, &v(json!([1, 2, 3, 4])), &v(json!([1, 2, 3, 5]))), None);
|
||||
assert_eq!(merge_items(&[], &v(json!([1])), &[]), Some(v(json!([1]))));
|
||||
}
|
||||