Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5830e4683 | ||
|
|
c7771c7bb7 | ||
|
|
8f6a123f29 | ||
|
|
6720ea4a21 | ||
|
|
f77d9a467e | ||
|
|
94349d86b0 | ||
|
|
0e6dc6871b | ||
|
|
2cf4510cbd | ||
|
|
47fff74655 | ||
|
|
1610d3db86 | ||
|
|
76929f63ce | ||
|
|
8df9998044 | ||
|
|
16fe57e94f | ||
|
|
a0d1e72c8d | ||
|
|
ab94cff913 | ||
|
|
9fde7fed3a | ||
|
|
98c4753118 | ||
|
|
e0435d4eb4 | ||
|
|
7edc61b8d2 | ||
|
|
faf2e78c9d | ||
|
|
b7af7c7ef7 |
No files matched your search
@@ -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,7 +11,7 @@ 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 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 |
|
||||
|
||||
@@ -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",
|
||||
@@ -1430,7 +1430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1444,7 +1444,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1459,7 +1459,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1482,7 +1482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1491,7 +1491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1503,7 +1503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1530,7 +1530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"flate2",
|
||||
@@ -1544,7 +1544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1552,7 +1552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1560,7 +1560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1576,7 +1576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1585,7 +1585,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1602,7 +1602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1615,7 +1615,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1633,7 +1633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1645,17 +1645,19 @@ dependencies = [
|
||||
"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",
|
||||
@@ -1669,7 +1671,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1682,7 +1684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
@@ -1705,7 +1707,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.3.1"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5801,14 +5803,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"
|
||||
|
||||
@@ -229,8 +229,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** host the open file from the app (this machine or your local network), share the invite link, and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (Share ▸ Publish to server…) and hands out share links that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
|
||||
- **Clean-room:** implemented from public documentation and observed behaviour only. No proprietary code, file formats, icons or images.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -26,7 +26,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| 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 experimental `.fig` export opens elsewhere (5–15) |
|
||||
| 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 | ~57% | ~60–110 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), onboarding and starter templates (5–10) |
|
||||
|
||||
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
|
||||
@@ -58,7 +58,7 @@ native speakers' reviews of the six language drafts need people.
|
||||
|---|---|---|---|
|
||||
| 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 | 60% (`.fig` read best effort with editable vectors; experimental write, checked only through our own importer) | 5–10 | [file-format-parity](docs/file-format-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) |
|
||||
@@ -123,7 +123,7 @@ IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-pa
|
||||
|
||||
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
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 experimental `.fig` export (landed 2026-10-12) on real files: 5–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: 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).
|
||||
@@ -131,6 +131,31 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or
|
||||
**Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that
|
||||
carries the host's address); others type it under Join with a code (the popover, the home screen), where a link
|
||||
works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`,
|
||||
`collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links)
|
||||
- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and
|
||||
compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing
|
||||
goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before
|
||||
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
|
||||
@@ -251,6 +276,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the 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 |
|
||||
|
||||
@@ -33,7 +33,13 @@ 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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, PluginEntry, PluginReply, PluginReq};
|
||||
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,13 +25,18 @@ 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)
|
||||
@@ -39,25 +44,48 @@ Options (also keys of the --config TOML file):
|
||||
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
|
||||
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
|
||||
max_connections, max_people, compact_after, compact_mb, max_plugins,
|
||||
max_plugin_mb, max_plugins_mb
|
||||
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,
|
||||
}
|
||||
@@ -81,6 +109,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
||||
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"));
|
||||
@@ -98,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()),
|
||||
}
|
||||
@@ -106,7 +143,7 @@ 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] => {
|
||||
@@ -115,6 +152,11 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
||||
["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()?;
|
||||
@@ -161,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() })? {
|
||||
@@ -281,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)
|
||||
}
|
||||
|
||||
@@ -302,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();
|
||||
@@ -337,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: "[email protected]".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,13 +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::abuse::AbuseConfig;
|
||||
use crate::directory::DirectoryConfig;
|
||||
use crate::store::StoreConfig;
|
||||
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;
|
||||
@@ -26,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.
|
||||
@@ -44,6 +45,30 @@ pub struct Config {
|
||||
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 {
|
||||
@@ -53,7 +78,7 @@ 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,
|
||||
@@ -62,6 +87,18 @@ impl Default for Config {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,13 +125,71 @@ 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_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"));
|
||||
}
|
||||
}
|
||||
for (name, v) in [
|
||||
("max_files", self.max_files as u64),
|
||||
("max_file_mb", self.max_file_mb),
|
||||
("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),
|
||||
@@ -107,7 +202,7 @@ impl Config {
|
||||
return Err(format!("`{name}` must be at least 1"));
|
||||
}
|
||||
}
|
||||
if self.max_file_mb > 4096 || self.compact_mb > 4096 {
|
||||
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.
|
||||
@@ -124,22 +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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ 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.
|
||||
@@ -80,7 +83,7 @@ impl Directory {
|
||||
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(64.0)), broken: false };
|
||||
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));
|
||||
@@ -173,6 +176,15 @@ impl Directory {
|
||||
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> {
|
||||
|
||||
@@ -15,11 +15,11 @@ use crate::directory::{DirectoryConfig, INDEX, PLUGINS};
|
||||
use crate::store::{Store, StoreConfig};
|
||||
use crate::test_util::{Raw, TempDir, config, key, session};
|
||||
|
||||
fn wasm() -> Vec<u8> {
|
||||
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()
|
||||
}
|
||||
|
||||
fn manifest(id: &str, version: &str) -> String {
|
||||
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()
|
||||
@@ -125,7 +125,7 @@ fn bad_bundles_caps_and_keys() {
|
||||
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..400).filter_map(|_| d.fetch(ID, None).err()).filter(|e| e.contains("busy")).count();
|
||||
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.
|
||||
|
||||
@@ -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,787 @@
|
||||
//! 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: "[email protected]".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");
|
||||
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,7 +8,9 @@
|
||||
//! 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;
|
||||
@@ -38,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();
|
||||
@@ -51,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}"))?;
|
||||
@@ -76,10 +91,16 @@ 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 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,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: "[email protected]".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"], "[email protected]");
|
||||
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"], "[email protected]");
|
||||
// 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.
|
||||
assert_eq!(manage(&mut a, json!({"action": "remove"})).unwrap()["done"], true);
|
||||
pump(&mut [&mut a, &mut b], "everyone told", |s| s.iter().all(|x| x.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: "[email protected]".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);
|
||||
@@ -202,6 +205,26 @@ fn fuzzed_frames_dont_hurt_the_server() {
|
||||
}),
|
||||
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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -210,7 +210,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,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 |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#02da96"/><path fill="#efe9dc" fill-rule="evenodd" d="M131 505C104 505 99 505 95 504C78 501 62 495 58 490C57 489 55 487 54 486C51 482 46 475 43 468C41 465 39 462 39 461C33 451 25 429 23 416C16 373 25 338 50 313C54 309 63 303 64 303C64 303 65 302 67 300C69 299 71 298 71 298C71 298 73 298 75 297C122 275 177 307 177 357C177 380 168 398 150 410C139 417 122 421 110 418C94 415 84 409 76 396C57 364 82 324 116 331C144 337 149 373 124 386C118 389 107 386 102 381C95 374 98 358 107 357C112 357 114 360 111 364C108 368 110 372 115 372C122 372 125 359 118 354C110 347 97 353 94 365C90 383 110 397 128 388C159 372 145 326 109 326C72 326 52 368 68 413C74 430 90 454 101 464C102 464 105 466 106 468C114 476 130 486 145 493C145 493 147 494 148 495C150 496 154 497 162 500C164 501 176 504 177 504C177 504 177 505 177 505C177 506 174 506 131 505ZM256 505C253 503 249 472 248 446C248 434 248 428 247 422C244 404 246 398 256 386C265 376 271 374 284 375C297 376 307 378 311 379C318 380 318 380 318 378C319 377 317 372 312 363C309 355 305 346 300 330C296 319 295 317 293 317C291 316 290 315 288 312C287 310 287 309 286 310C284 311 283 310 282 308C280 305 280 305 279 306C278 308 275 306 273 302C272 300 271 300 270 303C269 307 266 305 262 299C260 296 259 295 259 299C259 304 253 302 249 295C247 291 246 292 246 297C245 303 241 301 236 292C233 288 232 289 232 295C232 301 226 298 221 288C219 284 218 284 218 289C217 295 215 296 212 293C210 291 207 285 206 282C205 279 203 281 203 286C202 292 196 288 193 279C192 276 191 275 190 279C189 282 188 284 187 284C185 284 180 276 179 270C179 266 178 266 176 270C172 278 172 278 170 276C168 274 167 271 166 266C166 263 165 262 164 264C162 267 160 270 159 270C157 270 155 264 154 258C154 254 153 254 151 258C147 264 145 263 144 254C144 248 143 248 141 251C137 256 135 254 135 245C134 241 134 241 132 243C130 246 128 244 128 240L128 238 126 238C124 238 124 237 124 232L123 228 119 224C112 218 108 214 106 210C105 208 104 206 103 205C100 200 97 193 97 189C97 187 96 185 96 183C96 180 97 174 100 171C104 166 106 163 106 161C106 157 106 155 108 153C109 152 110 150 110 150C110 148 112 144 115 141C116 140 117 138 117 137C118 136 119 134 120 133C121 132 122 130 123 128C124 127 127 123 130 121C134 116 135 116 138 108C144 97 150 88 156 84C160 81 168 76 169 76C169 76 170 76 171 75C175 73 185 73 192 74C193 75 196 75 198 76C200 76 204 77 206 78L210 78 215 76C218 75 222 74 225 74C228 73 229 72 230 71C231 71 232 70 233 70C233 70 235 69 235 68C236 67 238 65 240 64C241 63 244 61 245 60C246 59 248 57 249 57C250 57 252 55 254 54C257 52 260 50 262 50C262 50 264 49 266 48C268 46 270 45 272 45C274 45 276 44 278 42C281 41 283 40 286 39C289 39 290 38 297 36C298 35 301 35 302 35C304 35 306 34 308 33C312 32 313 32 320 31C325 31 329 31 330 31C332 30 357 31 359 31C359 31 362 32 364 32C381 34 389 47 384 61C380 71 378 80 378 92L377 101 379 102C382 103 394 102 399 100C400 99 403 96 406 94C412 88 414 87 415 92C415 95 416 95 419 91C427 82 429 82 430 89C430 93 430 93 434 89C442 82 449 79 448 84C447 92 447 93 450 90C453 86 461 82 465 82C469 81 469 82 468 87C466 95 466 95 470 92C480 85 490 85 487 91C485 96 486 97 490 94C494 92 499 91 502 92C503 93 503 93 502 97C501 100 501 101 503 100C504 100 504 100 505 100C506 101 507 397 505 407C504 420 499 438 496 444C496 444 494 447 493 450C484 468 466 485 446 495C439 498 436 499 428 502C417 505 407 506 381 506C360 506 358 506 360 504C361 504 361 503 362 502C363 495 370 486 378 482C382 479 382 479 376 477C374 476 370 475 368 474C365 473 362 472 362 472C361 473 356 492 353 500C353 503 352 505 352 506C351 506 257 506 256 505ZM309 496C311 490 311 487 312 470C312 451 313 453 306 449C300 446 300 446 302 455C305 466 306 474 306 489C306 502 307 503 309 496ZM390 399C390 399 388 396 386 393C384 390 381 385 379 381C374 374 366 364 366 365C365 366 367 374 368 378C369 380 370 385 371 387C372 394 371 393 381 397C389 400 391 400 390 399Z"/><path fill="#0b0b0c" fill-rule="evenodd" d="M123 504C121 502 120 502 120 504C120 505 119 506 119 506C118 506 118 505 119 503C120 502 118 500 117 500C117 500 116 500 116 500C116 499 117 499 119 500C120 501 120 501 122 498C123 497 123 496 123 496C124 496 124 495 124 495C124 494 126 491 128 490C129 489 129 489 129 490C128 491 131 495 132 493C134 492 134 492 133 494C132 494 132 495 134 496C135 497 134 497 133 496C132 495 131 495 130 498C130 498 129 499 129 498C129 498 129 497 130 496L131 495 129 493L127 492 126 493C124 495 124 496 126 496C127 497 128 497 128 498C128 498 128 499 127 498C124 497 124 497 122 499L121 501 123 502C125 504 126 504 127 502C127 500 128 500 128 500C129 500 127 505 126 506C125 506 124 505 123 504ZM144 505C145 503 145 503 143 502C141 501 139 503 140 505C140 506 138 506 138 505C138 505 137 504 136 504C133 502 134 502 137 503C138 504 141 502 141 500C141 499 143 497 144 497C144 49Line truncated
|
||||
|
After Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 103 KiB |
@@ -1 +1 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"><g><path transform="matrix(1 0 0 1 0 0)" d="M179.2 0L332.8 0C395.526 0 426.889 0 450.847 12.207C471.921 22.945 489.055 40.079 499.793 61.153C512 85.111 512 116.474 512 179.2L512 332.8C512 395.526 512 426.889 499.793 450.847C489.055 471.921 471.921 489.055 450.847 499.793C426.889 512 395.526 512 332.8 512L179.2 512C116.474 512 85.111 512 61.153 499.793C40.079 489.055 22.945 471.921 12.207 450.847C0 426.889 0 395.526 0 332.8L0 179.2C0 116.474 0 85.111 12.207 61.153C22.945 40.079 40.079 22.945 61.153 12.207C85.111 0 116.474 0 179.2 0Z" fill="#0EA5C6"/></g><g><path transform="matrix(1 0 0 1 150 96)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 150 96)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.526 21.644C14.01 24.619 14 32.619 14 44.8L14 155.2C14 167.381 14.01 175.381 15.526 178.356C16.868 180.99 19.01 183.132 21.644 184.474C24.619 185.99 32.619 186 44.8 186L205.2 186C217.381 186 225.381 185.99 228.356 184.474C230.99 183.132 233.132 180.99 234.474 178.356C235.99 175.381 236 167.381 236 155.2L236 44.8C236 32.619 235.99 24.619 234.474 21.644C233.132 19.01 230.99 16.868 228.356 15.526C225.381 14.01 217.381 14 205.2 14L44.8 14Z" fill="#0B0B0C" fill-rule="evenodd"/></g><g><path transform="matrix(1 0 0 1 122 150)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 122 150)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.526 21.644C14.01 24.619 14 32.619 14 44.8L14 155.2C14 167.381 14.01 175.381 15.526 178.356C16.868 180.99 19.01 183.132 21.644 184.474C24.619 185.99 32.619 186 44.8 186L205.2 186C217.381 186 225.381 185.99 228.356 184.474C230.99 183.132 233.132 180.99 234.474 178.356C235.99 175.381 236 167.381 236 155.2L236 44.8C236 32.619 235.99 24.619 234.474 21.644C233.132 19.01 230.99 16.868 228.356 15.526C225.381 14.01 217.381 14 205.2 14L44.8 14Z" fill="#0B0B0C" fill-rule="evenodd"/></g><g><path transform="matrix(1 0 0 1 94 204)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 94 204)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.5Line truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#02da96"/><path fill="#efe9dc" fill-rule="evenodd" d="M105.6 505.5C99.1 505 89.7 503.4 83.5 501.6C80 500.6 73.9 498.6 73.6 498.4C73.4 498.2 72.4 497.8 71.4 497.5C68.8 496.7 61.6 493.2 59.8 491.9C59 491.3 57.8 490.1 57.2 489.2C56.6 488.3 55.4 486.8 54.6 485.9C52.1 483.2 47.4 476.4 44.9 471.9C43.6 469.7 41.5 465.9 40.2 463.6C19 426.3 14.4 381.3 28.1 346.2C33.1 333.6 39.6 323.6 49.1 314C54.4 308.7 57.7 306.1 64 302.4C84.1 290.3 101.6 287.3 124.1 292.1C146.3 296.8 164.7 312.7 173.3 334.6C176.6 343 177.5 348.8 177.1 359.9C176.7 370.9 175.4 376.8 171.4 385.2C163.2 402.3 149.5 413.1 129.5 418.2C124.7 419.4 113.8 419.3 107.9 418C91.3 414.3 81.2 406.5 73.9 391.7C69.7 383.2 68.4 374.5 70 365.5C72.3 352 79.3 341.5 90.5 334.7C98.3 330.1 111 329 120.2 332.3C128 335.1 135.5 342.2 138 349C141.6 359.2 140.7 368.8 135.2 376.9C133.1 379.9 132.8 380.2 128.7 383.1C124 386.2 122.2 386.8 117.8 386.8C107 386.9 98.9 381 98.1 372.7C97.6 367.4 99 362.1 101.6 359.7C104.3 357 107.5 356.1 110.2 357.4C113 358.6 113.3 361.4 111.1 364.3C108.4 367.7 109.5 370.6 113.8 371.7C118 372.9 121.9 368.5 122 362.8C122.1 356.6 117.4 351.6 110.6 350.7C106.1 350.1 99.5 353.8 96.4 358.7C93.8 362.9 92.8 370.9 94.3 375.5C96.8 382.8 102 387.6 110.1 390C113.4 391 120.6 390.9 123.7 389.8C137.4 385.2 146.2 371.9 144.7 358.2C143.7 348.3 139.2 339.3 132.8 334.1C118 322.1 96.3 323 80.9 336.3C60.3 354.2 56.7 387.8 71.7 421.6C74.8 428.5 76.8 432.3 79.2 435.9C79.9 436.9 81.5 439.3 82.7 441.2C93.2 457.3 109.3 472.5 128.1 484C131.3 486 141.8 491.4 143.7 492C144.5 492.3 146 493.1 146.9 493.7C149.7 495.6 161.4 500.2 167.3 501.8C168.9 502.2 171.2 502.8 172.4 503.3C173.6 503.7 175.7 504.3 177.1 504.6C178.4 505 179.5 505.4 179.5 505.6C179.5 505.9 111.2 505.8 105.6 505.5ZM255.2 505.6C254.8 504.9 252.8 493.7 251.6 485.4C249.4 470.1 248.7 460.4 248.3 440.8C248.1 431.2 247.6 425.4 246.1 416.3C244 403.7 246.6 396.6 257.8 383.9C263.5 377.5 269.7 374.4 277.2 374.4C279.1 374.4 281.3 374.6 282 374.8C282.8 375 285.4 375.3 287.7 375.4C296.8 376.1 309.3 377.9 314.4 379.2C318.5 380.3 318.9 380.2 318.6 378.5C318.3 376.5 317.6 374.5 316.2 371.2C315 368.7 314.3 367.2 310.4 358.8C308.1 354.1 305 345.4 300.9 332.8C298.9 326.7 296.7 320.6 296 319.1C294.7 316.6 294.7 316.6 293.3 316.6C291.2 316.6 289.8 315.3 288.4 312.2C287.2 309.5 286.8 309.2 285.5 310.1C284 311.3 282.4 310.2 281.2 307.3C280.2 304.6 280.1 304.5 278.9 305.8C277.7 307.1 277.8 307 276.1 306.2C274.8 305.5 274 304.5 272.3 301.4C271.6 300.1 270.9 300.4 270.5 302.3C270.1 304.4 269.8 304.8 268.3 304.8C266.7 304.8 264.1 302.2 261.8 298.4C259.9 295.1 259.1 295.2 259.1 298.9C259.1 302.1 257.9 303 255.5 301.6C253.1 300.2 250.7 297.8 249 295.1C246.6 291.5 245.8 291.8 245.8 296.2C245.8 303.1 241.1 301.2 235.7 292.2C234.7 290.6 233.7 289.3 233.5 289.2C232.9 289 232.5 290.6 232.2 293.8C231.9 298.3 230.6 299.3 227.8 297.4C225.7 295.8 223.8 293.3 221.5 289C220.4 286.8 219.2 285 219 285C218.5 285 217.8 287.6 217.4 290.9C216.6 298.5 210.1 293.7 206.3 282.7C205.8 281.2 205.2 279.9 204.9 279.9C204.4 279.9 203.1 283.1 202.9 285.2C202.3 290.5 200.8 291 197.3 286.9C195.4 284.7 193.6 281.3 192.7 277.9C191.9 275.3 191.2 275.2 190.5 277.7C189.7 280.8 188.3 284 187.9 284.2C185.3 285.2 180 276.8 179.2 270.3C178.6 265.7 177.5 266.1 175.3 271.8C173.9 275.3 172.9 277.1 172.2 277.4C171.2 277.7 169.3 276.1 168.3 274.1C167.2 271.9 165.6 265.2 165.5 262.7C165.5 261.7 164.2 263.1 162.7 265.5C161.7 267.1 160.5 268.7 160 269.1L159.1 269.8 158.2 268.9C155.9 266.9 154.6 263.2 154.1 257.8C154 256.2 153.7 254.9 153.6 254.8C153.2 254.6 152.3 255.7 150.4 258.7C148.3 261.8 147.8 262.2 146.5 261.5C145 260.6 144.4 259 144 253.9C143.5 247.3 143.6 247.3 140.8 251.1L138.8 253.7 137.4 253.4C136.6 253.3 135.9 253.1 135.8 253C135.1 252.3 134.7 249.8 134.6 246C134.5 240.9 134.3 240.4 132.9 242.2C130.3 245.8 128.5 245.1 127.9 240.4C127.4 236.7 127.5 236.8 126.3 237.5C124.6 238.5 123.9 237.5 123.5 233.1C123.1 228.5 122.7 227.9 119.9 225.4C111.9 218.5 108.2 214.4 105.7 209.6C104.6 207.4 103 204.6 102.2 203.4C101.4 202.1 100.7 201 100.7 200.9C100.7 200.8 99.8 199 98.8 196.7L96.8 192.7 96.8 189.3C96.8 187.3 96.6 185.5 96.3 185.1C95.6 183.8 95.8 180.3 96.9 177.1C98.1 173.5 98.4 173 100.4 170.5C104.4 165.3 105 164.2 105.3 161.1C105.6 157.1 106.3 155 107.5 153.8C109.1 152.3 109.8 151 110.2 148.8C110.7 146.3 112.5 143.4 115.1 140.7C116.2 139.6 117.1 138.4 117.1 138.1C117.1 137.2 119 133.7 120 132.8C120.5 132.3 121.4 131 122.1 129.9C124 126.7 126.9 123.4 130.3 120.3C133.6 117.4 134.4 116.2 136.7 111.3C137.4 109.9 138.1 108.4 138.3 108C139 106.8 139.2 106.3 140.3 104.1C142.2 99.9 148.2 91.5 152.3 87.2C155.5 83.8 158 81.9 162 79.6C163.3 78.9 165 77.8 165.8 77.3C166.7 76.6 167.8 76.1 168.8 76C169.7 75.9 170.9 75.5 171.5 75.1C174.3 73.4 185.1 72.8 190.9 74.1C192.6 74.4 195.8 75.1 198 75.6C20Line truncated
|
||||
|
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 |
@@ -1 +1 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"><g><path transform="matrix(1 0 0 1 0 0)" d="M179.2 0L332.8 0C395.526 0 426.889 0 450.847 12.207C471.921 22.945 489.055 40.079 499.793 61.153C512 85.111 512 116.474 512 179.2L512 332.8C512 395.526 512 426.889 499.793 450.847C489.055 471.921 471.921 489.055 450.847 499.793C426.889 512 395.526 512 332.8 512L179.2 512C116.474 512 85.111 512 61.153 499.793C40.079 489.055 22.945 471.921 12.207 450.847C0 426.889 0 395.526 0 332.8L0 179.2C0 116.474 0 85.111 12.207 61.153C22.945 40.079 40.079 22.945 61.153 12.207C85.111 0 116.474 0 179.2 0Z" fill="#0EA5C6"/></g><g><path transform="matrix(1 0 0 1 150 96)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 150 96)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.526 21.644C14.01 24.619 14 32.619 14 44.8L14 155.2C14 167.381 14.01 175.381 15.526 178.356C16.868 180.99 19.01 183.132 21.644 184.474C24.619 185.99 32.619 186 44.8 186L205.2 186C217.381 186 225.381 185.99 228.356 184.474C230.99 183.132 233.132 180.99 234.474 178.356C235.99 175.381 236 167.381 236 155.2L236 44.8C236 32.619 235.99 24.619 234.474 21.644C233.132 19.01 230.99 16.868 228.356 15.526C225.381 14.01 217.381 14 205.2 14L44.8 14Z" fill="#0B0B0C" fill-rule="evenodd"/></g><g><path transform="matrix(1 0 0 1 122 150)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 122 150)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.526 21.644C14.01 24.619 14 32.619 14 44.8L14 155.2C14 167.381 14.01 175.381 15.526 178.356C16.868 180.99 19.01 183.132 21.644 184.474C24.619 185.99 32.619 186 44.8 186L205.2 186C217.381 186 225.381 185.99 228.356 184.474C230.99 183.132 233.132 180.99 234.474 178.356C235.99 175.381 236 167.381 236 155.2L236 44.8C236 32.619 235.99 24.619 234.474 21.644C233.132 19.01 230.99 16.868 228.356 15.526C225.381 14.01 217.381 14 205.2 14L44.8 14Z" fill="#0B0B0C" fill-rule="evenodd"/></g><g><path transform="matrix(1 0 0 1 94 204)" d="M44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052C21.278 0 29.119 0 44.8 0Z" fill="#EFE9DC"/><path transform="matrix(1 0 0 1 94 204)" d="M15.288 3.052C21.278 0 29.119 0 44.8 0L205.2 0C220.881 0 228.722 0 234.712 3.052C239.98 5.736 244.264 10.02 246.948 15.288C250 21.278 250 29.119 250 44.8L250 155.2C250 170.881 250 178.722 246.948 184.712C244.264 189.98 239.98 194.264 234.712 196.948C228.722 200 220.881 200 205.2 200L44.8 200C29.119 200 21.278 200 15.288 196.948C10.02 194.264 5.736 189.98 3.052 184.712C0 178.722 0 170.881 0 155.2L0 44.8C0 29.119 0 21.278 3.052 15.288C5.736 10.02 10.02 5.736 15.288 3.052ZM44.8 14C32.619 14 24.619 14.01 21.644 15.526C19.01 16.868 16.868 19.01 15.5Line truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><clipPath id="t"><rect width="512" height="512" rx="112"/></clipPath></defs><g clip-path="url(#t)"><rect width="512" height="512" fill="#02da96"/><path fill="#efe9dc" fill-rule="evenodd" d="M131 505C104 505 99 505 95 504C78 501 62 495 58 490C57 489 55 487 54 486C51 482 46 475 43 468C41 465 39 462 39 461C33 451 25 429 23 416C16 373 25 338 50 313C54 309 63 303 64 303C64 303 65 302 67 300C69 299 71 298 71 298C71 298 73 298 75 297C122 275 177 307 177 357C177 380 168 398 150 410C139 417 122 421 110 418C94 415 84 409 76 396C57 364 82 324 116 331C144 337 149 373 124 386C118 389 107 386 102 381C95 374 98 358 107 357C112 357 114 360 111 364C108 368 110 372 115 372C122 372 125 359 118 354C110 347 97 353 94 365C90 383 110 397 128 388C159 372 145 326 109 326C72 326 52 368 68 413C74 430 90 454 101 464C102 464 105 466 106 468C114 476 130 486 145 493C145 493 147 494 148 495C150 496 154 497 162 500C164 501 176 504 177 504C177 504 177 505 177 505C177 506 174 506 131 505ZM256 505C253 503 249 472 248 446C248 434 248 428 247 422C244 404 246 398 256 386C265 376 271 374 284 375C297 376 307 378 311 379C318 380 318 380 318 378C319 377 317 372 312 363C309 355 305 346 300 330C296 319 295 317 293 317C291 316 290 315 288 312C287 310 287 309 286 310C284 311 283 310 282 308C280 305 280 305 279 306C278 308 275 306 273 302C272 300 271 300 270 303C269 307 266 305 262 299C260 296 259 295 259 299C259 304 253 302 249 295C247 291 246 292 246 297C245 303 241 301 236 292C233 288 232 289 232 295C232 301 226 298 221 288C219 284 218 284 218 289C217 295 215 296 212 293C210 291 207 285 206 282C205 279 203 281 203 286C202 292 196 288 193 279C192 276 191 275 190 279C189 282 188 284 187 284C185 284 180 276 179 270C179 266 178 266 176 270C172 278 172 278 170 276C168 274 167 271 166 266C166 263 165 262 164 264C162 267 160 270 159 270C157 270 155 264 154 258C154 254 153 254 151 258C147 264 145 263 144 254C144 248 143 248 141 251C137 256 135 254 135 245C134 241 134 241 132 243C130 246 128 244 128 240L128 238 126 238C124 238 124 237 124 232L123 228 119 224C112 218 108 214 106 210C105 208 104 206 103 205C100 200 97 193 97 189C97 187 96 185 96 183C96 180 97 174 100 171C104 166 106 163 106 161C106 157 106 155 108 153C109 152 110 150 110 150C110 148 112 144 115 141C116 140 117 138 117 137C118 136 119 134 120 133C121 132 122 130 123 128C124 127 127 123 130 121C134 116 135 116 138 108C144 97 150 88 156 84C160 81 168 76 169 76C169 76 170 76 171 75C175 73 185 73 192 74C193 75 196 75 198 76C200 76 204 77 206 78L210 78 215 76C218 75 222 74 225 74C228 73 229 72 230 71C231 71 232 70 233 70C233 70 235 69 235 68C236 67 238 65 240 64C241 63 244 61 245 60C246 59 248 57 249 57C250 57 252 55 254 54C257 52 260 50 262 50C262 50 264 49 266 48C268 46 270 45 272 45C274 45 276 44 278 42C281 41 283 40 286 39C289 39 290 38 297 36C298 35 301 35 302 35C304 35 306 34 308 33C312 32 313 32 320 31C325 31 329 31 330 31C332 30 357 31 359 31C359 31 362 32 364 32C381 34 389 47 384 61C380 71 378 80 378 92L377 101 379 102C382 103 394 102 399 100C400 99 403 96 406 94C412 88 414 87 415 92C415 95 416 95 419 91C427 82 429 82 430 89C430 93 430 93 434 89C442 82 449 79 448 84C447 92 447 93 450 90C453 86 461 82 465 82C469 81 469 82 468 87C466 95 466 95 470 92C480 85 490 85 487 91C485 96 486 97 490 94C494 92 499 91 502 92C503 93 503 93 502 97C501 100 501 101 503 100C504 100 504 100 505 100C506 101 507 397 505 407C504 420 499 438 496 444C496 444 494 447 493 450C484 468 466 485 446 495C439 498 436 499 428 502C417 505 407 506 381 506C360 506 358 506 360 504C361 504 361 503 362 502C363 495 370 486 378 482C382 479 382 479 376 477C374 476 370 475 368 474C365 473 362 472 362 472C361 473 356 492 353 500C353 503 352 505 352 506C351 506 257 506 256 505ZM309 496C311 490 311 487 312 470C312 451 313 453 306 449C300 446 300 446 302 455C305 466 306 474 306 489C306 502 307 503 309 496ZM390 399C390 399 388 396 386 393C384 390 381 385 379 381C374 374 366 364 366 365C365 366 367 374 368 378C369 380 370 385 371 387C372 394 371 393 381 397C389 400 391 400 390 399Z"/><path fill="#0b0b0c" fill-rule="evenodd" d="M123 504C121 502 120 502 120 504C120 505 119 506 119 506C118 506 118 505 119 503C120 502 118 500 117 500C117 500 116 500 116 500C116 499 117 499 119 500C120 501 120 501 122 498C123 497 123 496 123 496C124 496 124 495 124 495C124 494 126 491 128 490C129 489 129 489 129 490C128 491 131 495 132 493C134 492 134 492 133 494C132 494 132 495 134 496C135 497 134 497 133 496C132 495 131 495 130 498C130 498 129 499 129 498C129 498 129 497 130 496L131 495 129 493L127 492 126 493C124 495 124 496 126 496C127 497 128 497 128 498C128 498 128 499 127 498C124 497 124 497 122 499L121 501 123 502C125 504 126 504 127 502C127 500 128 500 128 500C129 500 127 505 126 506C125 506 124 505 123 504ZM144 505C145 503 145 503 143 502C141 501 139 503 140 505C140 506 138 506 138 505C138 505 137 504 136 504C133 502 134 502 137 503C138 504 141 502 141 500C141 499 143 497 144 497C144 49Line truncated
|
||||
|
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> {
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
//! connects to the endpoint and sends the token in its `Hello`. A server address for managing
|
||||
//! files carries the server's key the same way (`ws://host:port/?key=<key>`). Errors never echo a
|
||||
//! link or an address.
|
||||
//!
|
||||
//! A join by code names the server to ask and the code:
|
||||
//! `figcraft://join?server=<ws(s)://…>&code=<code>` ([`Invite::parse_join`]); the server says which
|
||||
//! file and token the code stands for.
|
||||
|
||||
/// Where a session or a server listens: `ws://host:port/path` or `wss://…` (TLS).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -134,6 +138,15 @@ impl Invite {
|
||||
|
||||
/// Read an invite link (any form; surrounding spaces, quotes or angle brackets are fine).
|
||||
pub fn parse(link: &str) -> Result<Invite, String> {
|
||||
match Self::parse_join(link)? {
|
||||
(invite, None) => Ok(invite),
|
||||
(_, Some(_)) => Err("that names a join code, not a file: join with it first".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read what a join starts from: an invite link, or a server with a join code to ask it about
|
||||
/// (then the invite names only the server, until the server says what the code opens).
|
||||
pub fn parse_join(link: &str) -> Result<(Invite, Option<String>), String> {
|
||||
let link = clean(link);
|
||||
if link.len() > MAX_LINK {
|
||||
return Err(NOT.into());
|
||||
@@ -158,9 +171,14 @@ impl Invite {
|
||||
return Err(NOT.into());
|
||||
};
|
||||
let get = |k: &str| query.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
|
||||
if let Some(code) = get("code") {
|
||||
let code = figcraft_collab::code::normalize(&code).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN);
|
||||
let code = code.ok_or("the link's join code isn't one")?;
|
||||
return Ok((Invite { server, file: String::new(), token: String::new() }, Some(code)));
|
||||
}
|
||||
let file = get("file").filter(|f| valid_id(f, 1, 64)).ok_or("the invite link has no valid file id")?;
|
||||
let token = get("token").filter(|t| valid_id(t, 16, 128)).ok_or("the invite link has no valid token")?;
|
||||
Ok(Invite { server, file, token })
|
||||
Ok((Invite { server, file, token }, None))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ pub(crate) const CLOSE_RESTART: u16 = 1012;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GuestConfig {
|
||||
pub invite: Invite,
|
||||
/// A join code to ask `invite.server` about first: its answer fills in the invite's file and
|
||||
/// token ([`resolved`]).
|
||||
pub code: Option<String>,
|
||||
/// Your name (empty: the host picks one).
|
||||
pub name: String,
|
||||
pub limits: NetLimits,
|
||||
@@ -44,6 +47,7 @@ impl GuestConfig {
|
||||
pub fn new(invite: Invite, name: String) -> Self {
|
||||
Self {
|
||||
invite,
|
||||
code: None,
|
||||
name,
|
||||
limits: NetLimits::default(),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
@@ -67,10 +71,36 @@ pub(crate) enum Outcome {
|
||||
Refused(String),
|
||||
/// The host ended the session: final.
|
||||
Ended(String),
|
||||
/// It can't go on, for this reason as it is shown: final.
|
||||
Failed(String),
|
||||
/// You left.
|
||||
Left,
|
||||
}
|
||||
|
||||
/// The server couldn't be asked about a join code, or said no: final, in words for the person who
|
||||
/// typed it (the server's own for a code it doesn't know).
|
||||
pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
|
||||
Outcome::Failed(match outcome {
|
||||
// A server from before join codes can't read the question.
|
||||
Outcome::Refused(why) if why.contains("couldn't be read") => {
|
||||
format!("{} doesn't know join codes yet (it runs an older version): ask for the invite link instead", cfg.invite.server.display())
|
||||
}
|
||||
Outcome::Refused(why) | Outcome::Failed(why) | Outcome::Ended(why) => why,
|
||||
Outcome::Retry(why) => format!("Couldn't reach the server ({why})"),
|
||||
Outcome::Left => return Outcome::Left,
|
||||
})
|
||||
}
|
||||
|
||||
/// The server's answer to the join code: the invite is whole now.
|
||||
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
|
||||
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
|
||||
return Err(Outcome::Failed("the server answered the join code with something that isn't a file".into()));
|
||||
}
|
||||
cfg.code = None;
|
||||
(cfg.invite.file, cfg.invite.token) = (file, token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a close frame from the host means.
|
||||
pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
|
||||
match code {
|
||||
@@ -97,6 +127,7 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
|
||||
match outcome {
|
||||
Outcome::Left => Next::Stop(None),
|
||||
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
|
||||
Outcome::Failed(why) => failed(why),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
|
||||
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
|
||||
Outcome::Retry(why) => {
|
||||
@@ -209,8 +240,10 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
|
||||
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
|
||||
// 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)),
|
||||
// Answers for managing and library connections: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) | ServerMsg::Plugins(_) => {}
|
||||
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,9 +5,11 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_engine::live::{
|
||||
Bind, Event, HostOptions, LiveLink, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo,
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionId,
|
||||
SessionInfo, Shared,
|
||||
};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
|
||||
@@ -16,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.
|
||||
@@ -29,12 +32,20 @@ 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 requests sent in the background (outlives sessions).
|
||||
@@ -47,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.
|
||||
@@ -62,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
|
||||
@@ -96,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();
|
||||
@@ -133,34 +156,29 @@ 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> {
|
||||
let (invite, code) = Invite::parse_join(link)?;
|
||||
let cfg = self.guest_config(invite, code, name);
|
||||
let inbox = self.sessions.inbox();
|
||||
let guest = Guest::start(cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
|
||||
self.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(())
|
||||
}
|
||||
|
||||
@@ -176,6 +194,14 @@ impl LiveLink for NetLink {
|
||||
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());
|
||||
@@ -184,6 +210,8 @@ impl LiveLink for NetLink {
|
||||
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) });
|
||||
});
|
||||
@@ -211,34 +239,31 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -248,7 +273,30 @@ 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, &key, &o.name, &o.doc, limits, CONNECT).map(|(_, published)| published)
|
||||
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).
|
||||
@@ -260,6 +308,8 @@ fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginRep
|
||||
|
||||
impl Drop for NetLink {
|
||||
fn drop(&mut self) {
|
||||
self.leave();
|
||||
for id in self.sessions.ids() {
|
||||
self.leave(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{
|
||||
self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply, PluginReq, ServerMsg,
|
||||
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};
|
||||
@@ -40,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.
|
||||
@@ -108,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,4 +274,9 @@ impl Bucket {
|
||||
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,7 @@ 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();
|
||||
}
|
||||
@@ -216,13 +216,13 @@ fn edits_sync_between_apps() {
|
||||
#[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);
|
||||
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"));
|
||||
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());
|
||||
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));
|
||||
@@ -235,17 +235,17 @@ fn cursor_chat_and_spotlight_between_apps() {
|
||||
}
|
||||
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
|
||||
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));
|
||||
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)));
|
||||
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());
|
||||
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();
|
||||
pump(&mut [&mut host, &mut guest], "the edits at the guest", |s| has(&*s[1], ga, in_a) && has(&*s[1], gb, in_b));
|
||||
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());
|
||||
assert_eq!(name(&guest, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,8 +449,11 @@ impl Client {
|
||||
self.presence.remove(&client_id);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
// Cursor chat and spotlights are the app's (the transport hands them over).
|
||||
ServerMsg::Pong | ServerMsg::Signal { .. } => 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 {
|
||||
|
||||
@@ -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,9 +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, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed, Resume, ServerMsg, Signal, 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};
|
||||
@@ -68,6 +68,14 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
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 };
|
||||
@@ -112,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 })),
|
||||
@@ -135,6 +143,40 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
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: "[email protected]".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)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
pub mod change;
|
||||
pub mod client;
|
||||
pub mod code;
|
||||
mod diff;
|
||||
mod docprops;
|
||||
pub mod frac;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,13 @@ pub const WIRE_VERSION: u8 = 1;
|
||||
/// server sends signals only to clients that say so (older ones couldn't read the message).
|
||||
pub const SIGNALS: &str = "signals";
|
||||
|
||||
/// The word a client puts in its [`Hello::client_version`] when it reads [`ServerMsg::Hosting`] and
|
||||
/// [`ServerMsg::Reported`]: a server sends them only to clients that say so.
|
||||
pub const HOSTING: &str = "hosting";
|
||||
|
||||
/// The `client_version` this build announces: its version, then what it reads beyond the protocol.
|
||||
pub fn client_version() -> String {
|
||||
format!("{} {SIGNALS}", env!("CARGO_PKG_VERSION"))
|
||||
format!("{} {SIGNALS} {HOSTING}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`SIGNALS`].
|
||||
@@ -29,6 +33,11 @@ pub fn reads_signals(client_version: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == SIGNALS)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`HOSTING`].
|
||||
pub fn reads_hosting(client_version: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == HOSTING)
|
||||
}
|
||||
|
||||
/// Join a file's session.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
@@ -245,6 +254,22 @@ pub enum Admin {
|
||||
Revoke { file: String, link: String },
|
||||
/// Remove a file and its links (whoever is in it is disconnected).
|
||||
Remove { file: String },
|
||||
/// Rename a file on the server (its listing; the document inside keeps its own name).
|
||||
Rename { file: String, name: String },
|
||||
/// The files kept, with what the operator moderates by ([`AdminReply::Listing`]).
|
||||
Listing,
|
||||
/// The reports people sent about files.
|
||||
Reports,
|
||||
/// Refuse connections from an address (an IPv6 address: its /64), and disconnect them.
|
||||
Block { ip: String },
|
||||
/// Accept an address again.
|
||||
Unblock { ip: String },
|
||||
/// The blocked addresses.
|
||||
Blocked,
|
||||
/// A join code for the link of `file` whose token this is (the token proves the link and lets
|
||||
/// the server give it to whoever types the code): [`AdminReply::Code`]. A link has one code:
|
||||
/// asking again replaces it. Servers older than join codes can't read this request.
|
||||
Code { file: String, token: String },
|
||||
}
|
||||
|
||||
/// A file a server keeps.
|
||||
@@ -303,6 +328,91 @@ pub enum AdminReply {
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
/// A file published on an open hosting connection: its first two links and its owner token
|
||||
/// (tokens shown once), and when it is deleted if nobody opens it (seconds since 1970).
|
||||
Hosted {
|
||||
file: FileInfo,
|
||||
edit: NewLink,
|
||||
view: NewLink,
|
||||
owner: String,
|
||||
expires: Option<u64>,
|
||||
},
|
||||
Listing(Vec<FileRow>),
|
||||
Reports(Vec<ReportInfo>),
|
||||
Blocked(Vec<String>),
|
||||
/// A link's new join code (shown this once; the server keeps only a hash).
|
||||
Code {
|
||||
code: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A file with what its operator moderates by.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FileRow {
|
||||
pub info: FileInfo,
|
||||
/// When someone last opened it (seconds since 1970; 0: never since it was published).
|
||||
pub opened: u64,
|
||||
/// When it is deleted if nobody opens it (files published without the server key).
|
||||
pub expires: Option<u64>,
|
||||
/// Published without the server key (it has an owner token), from this address.
|
||||
pub open: bool,
|
||||
pub ip: String,
|
||||
/// Reports kept about it.
|
||||
pub reports: u32,
|
||||
}
|
||||
|
||||
/// A report someone in a file sent its server's operator.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ReportInfo {
|
||||
/// Seconds since 1970.
|
||||
pub at: u64,
|
||||
pub file: String,
|
||||
/// The file's name then.
|
||||
pub name: String,
|
||||
/// The link the reporter joined with, and their address.
|
||||
pub link: String,
|
||||
pub ip: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Opens a connection to a server's open hosting (instead of [`Hello`]): anyone may ask about the
|
||||
/// server and, when it is open to the public, publish a file without the server key; with a file's
|
||||
/// owner token (`file` and `owner` set) the connection also manages that file. Answered with
|
||||
/// [`ServerMsg::Hosting`] or `Refused`; requests are [`Admin`] messages, each answered with one
|
||||
/// [`AdminReply`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OpenHosting {
|
||||
pub protocol: u32,
|
||||
pub client_version: String,
|
||||
pub file: String,
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
/// Asks a server which file a join code opens (the first message on a connection, instead of
|
||||
/// [`Hello`]). Answered with [`ServerMsg::Resolved`], then the connection closes; a wrong code, too
|
||||
/// many wrong ones from an address, or a server that hosts no files is `Refused`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Resolve {
|
||||
pub protocol: u32,
|
||||
pub client_version: String,
|
||||
/// The code's symbols ([`crate::code::normalize`]d).
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// What a server says about its hosting: whether anyone may publish, who runs it and its terms,
|
||||
/// how long files published without the key are kept unopened, and the largest file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostInfo {
|
||||
/// Anyone may publish without the server key.
|
||||
pub open: bool,
|
||||
/// How to reach the operator (an email address or a web page), and the terms of use (a URL).
|
||||
pub operator: String,
|
||||
pub terms: String,
|
||||
/// Files published without the key are deleted after this many seconds unopened (0: kept).
|
||||
pub idle_secs: u64,
|
||||
pub max_file_mb: u64,
|
||||
/// This file's expiry (seconds since 1970), when it is one that expires.
|
||||
pub expires: Option<u64>,
|
||||
}
|
||||
|
||||
/// Opens a connection that uses a file's team library (instead of [`Hello`]), with one of the
|
||||
@@ -481,6 +591,16 @@ pub enum ClientMsg {
|
||||
OpenPlugins(OpenPlugins),
|
||||
/// A request on a plugin directory connection.
|
||||
Plugins(PluginReq),
|
||||
/// Ask about the server's hosting, publish without the key or manage a file with its owner
|
||||
/// token (the first message on a connection, instead of `Hello`).
|
||||
OpenHosting(OpenHosting),
|
||||
/// Report the file you are in to the server's operator (only to a server that sent
|
||||
/// [`ServerMsg::Hosting`]): why, in a few words.
|
||||
Report {
|
||||
reason: String,
|
||||
},
|
||||
/// Ask which file a join code opens (the first message on a connection, instead of `Hello`).
|
||||
Resolve(Resolve),
|
||||
}
|
||||
|
||||
/// Server → client.
|
||||
@@ -536,6 +656,37 @@ pub enum ServerMsg {
|
||||
},
|
||||
/// The answer to a plugin directory connection's request.
|
||||
Plugins(Box<PluginReply>),
|
||||
/// The server's hosting: the answer to [`OpenHosting`], and after a welcome to clients whose
|
||||
/// hello announced [`HOSTING`] (with the file's expiry).
|
||||
Hosting(Box<HostInfo>),
|
||||
/// The answer to a [`ClientMsg::Report`]: kept, or why not.
|
||||
Reported {
|
||||
error: Option<String>,
|
||||
},
|
||||
/// The answer to [`ClientMsg::Resolve`]: the file the code opens and its link's token (what a
|
||||
/// `Hello` joins with).
|
||||
Resolved {
|
||||
file: String,
|
||||
token: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
|
||||
/// messages).
|
||||
pub fn utc_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")
|
||||
}
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
|
||||
@@ -63,8 +63,9 @@ pub struct Server {
|
||||
issued: BTreeMap<u32, Processed>,
|
||||
/// The latest accepted batches, oldest first (a reconnecting client gets what it missed).
|
||||
log: VecDeque<Accepted>,
|
||||
/// Changes in `log`.
|
||||
/// Changes in `log`, and about how many bytes it holds ([`weight`]).
|
||||
log_changes: usize,
|
||||
log_bytes: usize,
|
||||
/// The connected clients that may only view.
|
||||
viewers: BTreeSet<u32>,
|
||||
/// What changed the lasting state since it was last taken (`None`: not kept).
|
||||
@@ -117,6 +118,7 @@ impl Server {
|
||||
issued: BTreeMap::new(),
|
||||
log: VecDeque::new(),
|
||||
log_changes: 0,
|
||||
log_bytes: 0,
|
||||
viewers: BTreeSet::new(),
|
||||
journal: None,
|
||||
signals: BTreeSet::new(),
|
||||
@@ -388,7 +390,10 @@ impl Server {
|
||||
| ClientMsg::OpenLibrary(_)
|
||||
| ClientMsg::Library(_)
|
||||
| ClientMsg::OpenPlugins(_)
|
||||
| ClientMsg::Plugins(_) => {
|
||||
| ClientMsg::Plugins(_)
|
||||
| ClientMsg::OpenHosting(_)
|
||||
| ClientMsg::Resolve(_)
|
||||
| ClientMsg::Report { .. } => {
|
||||
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })]
|
||||
}
|
||||
}
|
||||
@@ -397,14 +402,25 @@ impl Server {
|
||||
/// Keep an accepted batch in the log (dropping the oldest beyond the limits).
|
||||
fn remember(&mut self, acc: Accepted) {
|
||||
self.log_changes += acc.changes.len();
|
||||
self.log_bytes = self.log_bytes.saturating_add(weight(&acc.changes));
|
||||
self.log.push_back(acc);
|
||||
while self.log.len() > self.limits.max_log_batches || (self.log_changes > self.limits.max_log_changes && !self.log.is_empty()) {
|
||||
while !self.log.is_empty()
|
||||
&& (self.log.len() > self.limits.max_log_batches
|
||||
|| self.log_changes > self.limits.max_log_changes
|
||||
|| self.log_bytes > self.limits.max_log_bytes)
|
||||
{
|
||||
if let Some(old) = self.log.pop_front() {
|
||||
self.log_changes = self.log_changes.saturating_sub(old.changes.len());
|
||||
self.log_bytes = self.log_bytes.saturating_sub(weight(&old.changes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// About how many bytes the batches kept for reconnecting clients hold.
|
||||
pub fn log_bytes(&self) -> usize {
|
||||
self.log_bytes
|
||||
}
|
||||
|
||||
/// Validate and apply a batch; on success it has the next sequence number.
|
||||
fn accept(&mut self, from: u32, base_seq: u64, changes: &[Change]) -> Result<(), Error> {
|
||||
if changes.len() > self.limits.max_changes {
|
||||
@@ -557,3 +573,27 @@ fn reason(e: &Error) -> String {
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// About how many bytes `changes` take in memory: their values' text and a little per change.
|
||||
pub fn weight(changes: &[Change]) -> usize {
|
||||
fn json(v: &serde_json::Value) -> usize {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.len() + 2,
|
||||
serde_json::Value::Array(a) => a.iter().map(json).fold(2, usize::saturating_add),
|
||||
serde_json::Value::Object(o) => o.iter().map(|(k, v)| k.len() + 3 + json(v)).fold(2, usize::saturating_add),
|
||||
_ => 8,
|
||||
}
|
||||
}
|
||||
changes
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
Change::Create { kind, position, props, .. } => {
|
||||
props.iter().map(|(_, v)| 16 + json(&v.0)).fold(64 + json(&kind.0) + position.len(), usize::saturating_add)
|
||||
}
|
||||
Change::Set { value, .. } | Change::SetDoc { value, .. } => 32 + json(&value.0),
|
||||
Change::Put { value, .. } => 64 + json(&value.0),
|
||||
Change::Delete { .. } => 16,
|
||||
Change::Remove { .. } => 64,
|
||||
})
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
@@ -32,17 +32,17 @@ struct Wire {
|
||||
struct Link(Arc<Mutex<Wire>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Err("no".into())
|
||||
}
|
||||
fn leave(&mut self) {}
|
||||
fn presence(&mut self, _: &Presence) {}
|
||||
fn send(&mut self, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.0.lock().unwrap().answers)
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
std::mem::take(&mut self.0.lock().unwrap().answers).into_iter().map(|e| (0, e)).collect()
|
||||
}
|
||||
fn plugins(&mut self, _: &str, req: PluginReq) -> Result<PluginReply, String> {
|
||||
self.0.lock().unwrap().waited += 1;
|
||||
@@ -111,6 +111,8 @@ fn answer(s: &mut Session, wire: &Arc<Mutex<Wire>>) -> usize {
|
||||
NetRequest::Plugins { req, .. } => NetReply::Plugins(Ok(directory(req))),
|
||||
NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))),
|
||||
NetRequest::Upload(_) => NetReply::Upload(Err("no".into())),
|
||||
NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())),
|
||||
NetRequest::Share { .. } => NetReply::Share(Err("no".into())),
|
||||
};
|
||||
w.answers.push(Event::Net { job: *job, reply: Box::new(reply) });
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ fn publish_to_server(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let link = match server {
|
||||
// The server's address with its key: the file is kept there first.
|
||||
Some(server) if key.is_some() || (server.contains("key=") && !server.contains("token=")) => {
|
||||
let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone() };
|
||||
let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone(), share: false };
|
||||
let file = match s.net(NetRequest::Upload(opts))?.0 {
|
||||
NetReply::Upload(r) => r.map_err(|e| bad(cmd, e))?,
|
||||
_ => return Err(EngineError::Other("the server answered something else".into())),
|
||||
|
||||
@@ -290,6 +290,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name };
|
||||
Ok(json!({"name": s.prefs.user_name}))
|
||||
}),
|
||||
spec!(
|
||||
"pref.publishServer",
|
||||
"Server for online sharing",
|
||||
"{url?: ws://host:port/ or wss://host/path/ (no key; empty: online sharing off; omit: back to the built-in server)} → {url, default (the built-in server)}: where Share ▸ Online keeps files and where join codes are asked",
|
||||
false,
|
||||
|s, p| {
|
||||
const CMD: &str = "pref.publishServer";
|
||||
let url = match get(p, "url") {
|
||||
None => crate::live::DEFAULT_SERVER.to_string(),
|
||||
Some(_) => req_string(CMD, p, "url")?.trim().to_string(),
|
||||
};
|
||||
let web_socket = url.starts_with("ws://") || url.starts_with("wss://");
|
||||
if !url.is_empty()
|
||||
&& (!web_socket || url.len() > 500 || url.contains(|c: char| c.is_whitespace() || c.is_control() || c == '?' || c == '#'))
|
||||
{
|
||||
return Err(bad(
|
||||
CMD,
|
||||
"the server's address is ws://host:port/ or wss://host/path/, without a key (it is saved with your preferences)",
|
||||
));
|
||||
}
|
||||
s.prefs.publish_server = url;
|
||||
Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER}))
|
||||
}
|
||||
),
|
||||
spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
let lang = lang.trim();
|
||||
|
||||
@@ -55,7 +55,7 @@ impl Room {
|
||||
let w = welcome_of(&out, id);
|
||||
script.lock().unwrap().events.extend([Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, Event::Sync(w)]);
|
||||
s.live_poll();
|
||||
assert!(s.live.syncing() && s.live_doc(s.ds().unwrap().uid), "joined: the shared file is the active tab");
|
||||
assert!(s.live().syncing() && s.live_doc(s.ds().unwrap().uid), "joined: the shared file is the active tab");
|
||||
room.people.push(Person { s, script, id, up: VecDeque::new(), down: VecDeque::new(), online: true });
|
||||
room.route(out.into_iter().filter(|o| !matches!(o, Outgoing::To(_, ServerMsg::Welcome { .. }))).collect());
|
||||
}
|
||||
@@ -142,7 +142,7 @@ impl Room {
|
||||
fn reconnect(&mut self, i: usize) {
|
||||
let p = &mut self.people[i];
|
||||
let ClientMsg::Hello(mut h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") };
|
||||
h.resume = p.s.live.client().map(Client::resume);
|
||||
h.resume = p.s.live().client().map(Client::resume);
|
||||
let (id, out) = self.server.join(h).unwrap();
|
||||
assert_eq!(id, p.id, "the same client");
|
||||
p.online = true;
|
||||
@@ -153,7 +153,7 @@ impl Room {
|
||||
/// Everyone holds the server's document, in the session and on the canvas.
|
||||
pub(crate) fn check(&self) {
|
||||
for (i, p) in self.people.iter().enumerate() {
|
||||
let c = p.s.live.client().unwrap();
|
||||
let c = p.s.live().client().unwrap();
|
||||
assert_eq!(c.pending(), 0, "person {i}");
|
||||
assert!(c.view() == self.server.replica(), "person {i}: {}", figcraft_collab::sim::describe(self.server.replica(), c.view()));
|
||||
assert!(*p.s.doc().unwrap() == *self.server.document(), "person {i}: the canvas shows the session's document");
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Double-clicks as a mouse sends them (press, release, press, double-click, with a pixel or two
|
||||
//! of hand movement between) into a group's text, and the drag threshold that keeps such clicks
|
||||
//! from moving or resizing what they land on.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text).
|
||||
fn scene(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap();
|
||||
let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap();
|
||||
let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
|
||||
let g = id_of(&ex(s, "object.group", json!({"ids": [r, t]}))["id"]).unwrap();
|
||||
ex(s, "select.none", json!({}));
|
||||
(g, t)
|
||||
}
|
||||
|
||||
fn send(s: &mut Session, kind: PointerKind, x: f64, y: f64, zoom: f64) {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, zoom)).unwrap();
|
||||
}
|
||||
|
||||
/// A click at (x, y) whose release is `jitter` away.
|
||||
fn click(s: &mut Session, x: f64, y: f64, jitter: f64, zoom: f64) {
|
||||
send(s, PointerKind::Down, x, y, zoom);
|
||||
send(s, PointerKind::Move, x + jitter, y, zoom);
|
||||
send(s, PointerKind::Up, x + jitter, y, zoom);
|
||||
}
|
||||
|
||||
/// What the canvas sends for a double-click: a click, a second press, the double-click (no
|
||||
/// release), then the pointer hovering on.
|
||||
fn double_click(s: &mut Session, x: f64, y: f64, jitter: f64, zoom: f64) {
|
||||
click(s, x, y, jitter, zoom);
|
||||
send(s, PointerKind::Down, x, y, zoom);
|
||||
send(s, PointerKind::Move, x + jitter, y, zoom);
|
||||
send(s, PointerKind::DoubleClick, x + jitter, y, zoom);
|
||||
send(s, PointerKind::Move, x + jitter + 1.0, y, zoom);
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> Option<NodeId> {
|
||||
s.ds().unwrap().text_edit.as_ref().map(|t| t.id)
|
||||
}
|
||||
|
||||
fn origin(s: &Session, id: NodeId) -> (f64, f64, f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.x0, b.y0, b.width(), b.height())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() {
|
||||
// The middle of the text (a move would start there), near its edges (a resize would) and
|
||||
// with the hand moving a pixel or two between press and release.
|
||||
for (x, y) in [(100.0, 70.0), (100.0, 61.0), (100.0, 78.0), (61.0, 70.0), (80.0, 70.0)] {
|
||||
for jitter in [0.0, 1.0, 2.0] {
|
||||
let mut s = Session::new();
|
||||
let (g, t) = scene(&mut s);
|
||||
let before = origin(&s, t);
|
||||
click(&mut s, x, y, jitter, 1.0);
|
||||
assert_eq!(s.selection(), vec![g], "a click picks the group ({x}, {y}, {jitter})");
|
||||
double_click(&mut s, x, y, jitter, 1.0);
|
||||
assert_eq!(s.selection(), vec![t], "a double-click picks the text ({x}, {y}, {jitter})");
|
||||
assert_eq!(editing(&s), None);
|
||||
double_click(&mut s, x, y, jitter, 1.0);
|
||||
assert_eq!(editing(&s), Some(t), "the next one edits it ({x}, {y}, {jitter})");
|
||||
assert_eq!(origin(&s, t), before, "and nothing was dragged ({x}, {y}, {jitter})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
|
||||
for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] {
|
||||
let mut s = Session::new();
|
||||
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap();
|
||||
ex(&mut s, "view.snapPixel", json!({"on": false}));
|
||||
// A move from the middle.
|
||||
send(&mut s, PointerKind::Down, 100.0, 100.0, zoom);
|
||||
send(&mut s, PointerKind::Move, 100.0 + by, 100.0, zoom);
|
||||
send(&mut s, PointerKind::Up, 100.0 + by, 100.0, zoom);
|
||||
assert_eq!(origin(&s, r).0 != 0.0, moves, "move by {by} at {zoom}");
|
||||
// Past the threshold the layer follows the pointer from where it was pressed, and coming
|
||||
// back near the start keeps following.
|
||||
send(&mut s, PointerKind::Down, 100.0, 100.0, zoom);
|
||||
send(&mut s, PointerKind::Move, 100.0 + 40.0 / zoom, 100.0, zoom);
|
||||
send(&mut s, PointerKind::Move, 100.0 + 1.0 / zoom, 100.0, zoom);
|
||||
let x = origin(&s, r).0;
|
||||
send(&mut s, PointerKind::Up, 100.0 + 1.0 / zoom, 100.0, zoom);
|
||||
s.undo().unwrap();
|
||||
assert!(x != 0.0 || !moves, "back near the start still follows at {zoom}");
|
||||
}
|
||||
// A resize from the right edge.
|
||||
for (by, resizes) in [(2.0, false), (4.0, true)] {
|
||||
let mut s = Session::new();
|
||||
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap();
|
||||
send(&mut s, PointerKind::Down, 200.0, 100.0, 1.0);
|
||||
send(&mut s, PointerKind::Move, 200.0 + by, 100.0, 1.0);
|
||||
send(&mut s, PointerKind::Up, 200.0 + by, 100.0, 1.0);
|
||||
assert_eq!(origin(&s, r).2 != 200.0, resizes, "resize by {by}");
|
||||
}
|
||||
}
|
||||
@@ -564,6 +564,10 @@ pub struct Prefs {
|
||||
/// The plugin directory browsed last (`ws://host:port/`, never with a key).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_directory: Option<String>,
|
||||
/// The server files are shared through online (Share ▸ Online, join codes; Publish to server
|
||||
/// suggests it too): `wss://host/`, never with a key. [`live::DEFAULT_SERVER`] unless the user
|
||||
/// chose another; empty: online sharing is off (saved as empty, so it stays off).
|
||||
pub publish_server: String,
|
||||
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
|
||||
/// [`cmd::file::MAX_RECENT`]).
|
||||
pub recent_files: Vec<String>,
|
||||
@@ -587,6 +591,7 @@ impl Default for Prefs {
|
||||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
plugin_directory: None,
|
||||
publish_server: live::DEFAULT_SERVER.into(),
|
||||
recent_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -636,8 +641,9 @@ pub struct Session {
|
||||
pub libraries: library::Cache,
|
||||
/// Plugin modules loaded in this session and the last plugin run.
|
||||
pub plugins: plugins::Runtime,
|
||||
/// The live session (collaboration): status, peers, presence.
|
||||
pub live: live::Live,
|
||||
/// The live sessions (collaboration), one per document at most: status, peers, presence.
|
||||
/// [`Session::live`] is the active document's.
|
||||
pub live: live::Lives,
|
||||
/// Commands the UI runs without waiting on a server ([`Session::start_background`]).
|
||||
pub background: background::Background,
|
||||
}
|
||||
@@ -671,7 +677,7 @@ impl Session {
|
||||
message: None,
|
||||
libraries: library::Cache::default(),
|
||||
plugins: plugins::Runtime::default(),
|
||||
live: live::Live::default(),
|
||||
live: live::Lives::default(),
|
||||
background: background::Background::default(),
|
||||
}
|
||||
}
|
||||
@@ -831,7 +837,7 @@ impl Session {
|
||||
/// synced and the step is the user's own ([`live`]); otherwise it is a snapshot of `before`.
|
||||
pub(crate) fn record(&mut self, uid: u64, label: Option<&str>, before: Arc<Document>, selection: Vec<NodeId>, page: NodeId) {
|
||||
if self.live_doc(uid) {
|
||||
return self.live_edit(label);
|
||||
return self.live_edit(uid, label);
|
||||
}
|
||||
if let (Some(label), Some(ds)) = (label, self.docs.iter_mut().find(|d| d.uid == uid)) {
|
||||
ds.push_undo(label, before, selection, page);
|
||||
@@ -841,7 +847,7 @@ impl Session {
|
||||
/// How many undo and redo steps the active document has (the user's own in a live session).
|
||||
pub fn undo_depth(&self) -> (usize, usize) {
|
||||
match self.ds() {
|
||||
Ok(ds) if self.live_doc(ds.uid) => self.live.client().map_or((0, 0), figcraft_collab::Client::history_len),
|
||||
Ok(ds) if self.live_doc(ds.uid) => self.live().client().map_or((0, 0), figcraft_collab::Client::history_len),
|
||||
Ok(ds) => (ds.history.undo.len(), ds.history.redo.len()),
|
||||
Err(_) => (0, 0),
|
||||
}
|
||||
@@ -851,7 +857,7 @@ impl Session {
|
||||
pub fn undo_labels(&self, n: usize) -> Vec<String> {
|
||||
match self.ds() {
|
||||
Ok(ds) if self.live_doc(ds.uid) => {
|
||||
self.live.client().map(|c| c.undo_labels().rev().take(n).map(str::to_string).collect()).unwrap_or_default()
|
||||
self.live().client().map(|c| c.undo_labels().rev().take(n).map(str::to_string).collect()).unwrap_or_default()
|
||||
}
|
||||
Ok(ds) => ds.history.undo.iter().rev().take(n).map(|h| h.label.clone()).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
@@ -1303,6 +1309,8 @@ mod devfocus_tests;
|
||||
#[cfg(test)]
|
||||
mod devinspect_tests;
|
||||
#[cfg(test)]
|
||||
mod double_click_tests;
|
||||
#[cfg(test)]
|
||||
mod draw_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
@@ -1343,6 +1351,10 @@ mod library_variables_tests;
|
||||
#[cfg(test)]
|
||||
mod live_access_tests;
|
||||
#[cfg(test)]
|
||||
mod live_docs_tests;
|
||||
#[cfg(test)]
|
||||
mod live_share_tests;
|
||||
#[cfg(test)]
|
||||
mod live_signal_tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
@@ -1437,6 +1449,8 @@ mod text_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod text_fill_tests;
|
||||
#[cfg(test)]
|
||||
mod text_metrics_local_tests;
|
||||
#[cfg(test)]
|
||||
mod text_motion_tests;
|
||||
#[cfg(test)]
|
||||
mod text_style_picker_tests;
|
||||
|
||||
@@ -92,8 +92,8 @@ impl Session {
|
||||
/// you may edit (where `library.publishToServer` publishes by default).
|
||||
pub fn server_link(&self) -> Option<String> {
|
||||
let uid = self.ds().ok()?.uid;
|
||||
let info = self.live.info.as_ref().filter(|i| i.role == crate::live::Role::Guest && i.access == crate::live::Access::Edit)?;
|
||||
self.live_doc(uid).then(|| info.link.clone())
|
||||
let live = Some(self.live_of(uid)).filter(|l| l.syncing() && l.doc_uid == Some(uid))?;
|
||||
live.info.as_ref().filter(|i| i.role == crate::live::Role::Guest && i.access == crate::live::Access::Edit).map(|i| i.link.clone())
|
||||
}
|
||||
|
||||
/// One request about the team library behind share link `link` (a refusal is an error), with
|
||||
|
||||
@@ -46,15 +46,23 @@ fn publishing_makes_the_open_tab_the_server_file() {
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
doc.name = "Team".into();
|
||||
let (mut server, id, welcome) = serve_as(&doc, Access::Edit);
|
||||
let published = Published { server: "127.0.0.1:47900".into(), file: "f".into(), name: "Team".into(), edit: "e".into(), view: "v".into() };
|
||||
let published = Published {
|
||||
server: "127.0.0.1:47900".into(),
|
||||
file: "f".into(),
|
||||
name: "Team".into(),
|
||||
edit: "e".into(),
|
||||
view: "v".into(),
|
||||
owner: None,
|
||||
expires: None,
|
||||
};
|
||||
push(&script, vec![Event::Published(published.clone()), Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live.syncing());
|
||||
assert!(s.live().syncing());
|
||||
assert_eq!(s.docs.len(), 1, "no new tab");
|
||||
assert_eq!(s.doc().unwrap().name, "Team");
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
assert_eq!(s.active_read_only(), None);
|
||||
assert_eq!(s.live.published, Some(published));
|
||||
assert_eq!(s.live().published, Some(published));
|
||||
assert_eq!(s.message.as_deref(), Some("Published Team to the server"));
|
||||
assert_eq!(s.live_summary()["published"]["name"], "Team");
|
||||
// Edits sync through the server.
|
||||
@@ -85,7 +93,7 @@ fn a_view_link_makes_the_file_read_only() {
|
||||
me.access = Access::View;
|
||||
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live.syncing());
|
||||
assert!(s.live().syncing());
|
||||
assert_eq!(s.active_read_only(), Some(VIEW_ONLY));
|
||||
let summary = s.live_summary();
|
||||
assert_eq!((summary["access"].as_str(), summary["readOnly"].as_bool()), (Some("view"), Some(true)));
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Live sessions are per document, against the scripted link: every open document has its own
|
||||
//! session or none, several run at once, the `collab.*` commands and `collab.peers` are the
|
||||
//! active document's, a join shows where it was started until its file opens in its own tab, and
|
||||
//! closing a document ends its session only.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_collab::{Client, Outgoing, Server};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::live::{Event, PUBLISHING, Presence, Role, Signal, Status};
|
||||
use crate::live_tests::{Script, info, peer, push, relay, serve, session};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
const LINK: &str = "figcraft://join?host=a:1&file=f&token=t";
|
||||
|
||||
/// Hand `events` to the session as link session `id`'s.
|
||||
fn feed(s: &mut Session, script: &Arc<Mutex<Script>>, id: u64, events: Vec<Event>) {
|
||||
script.lock().unwrap().session = id;
|
||||
push(script, events);
|
||||
s.live_poll();
|
||||
}
|
||||
|
||||
fn uid(s: &Session) -> u64 {
|
||||
s.ds().unwrap().uid
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> u64 {
|
||||
v["session"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
/// Host the active document and start syncing it: its uid, its session id, and its session's
|
||||
/// server with your client id there.
|
||||
fn host(s: &mut Session, script: &Arc<Mutex<Script>>) -> (u64, u64, Server, u32) {
|
||||
let id = id_of(&run(s, "collab.host", json!({})));
|
||||
let (server, me, welcome) = serve(s.doc().unwrap());
|
||||
feed(s, script, id, vec![welcome]);
|
||||
assert!(s.live_shared());
|
||||
(uid(s), id, server, me)
|
||||
}
|
||||
|
||||
/// Two documents, each hosted and syncing (the second in front): their uids, session ids and
|
||||
/// the scripted link.
|
||||
fn two_hosted() -> (Session, Arc<Mutex<Script>>, [u64; 2], [u64; 2]) {
|
||||
let (mut s, script) = session();
|
||||
let (ua, a, ..) = host(&mut s, &script);
|
||||
run(&mut s, "file.new", json!({"name": "Second"}));
|
||||
let (ub, b, ..) = host(&mut s, &script);
|
||||
(s, script, [ua, ub], [a, b])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_document_has_its_own_session() {
|
||||
let (mut s, script) = session();
|
||||
let a = run(&mut s, "collab.host", json!({}));
|
||||
let ua = uid(&s);
|
||||
// A new document isn't in the first one's session.
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let ub = uid(&s);
|
||||
let off = run(&mut s, "collab.peers", json!({}));
|
||||
for key in ["session", "role", "link", "url", "address", "fileName", "document", "me"] {
|
||||
assert_eq!(off[key], Value::Null, "{key}: {off}");
|
||||
}
|
||||
assert_eq!((&off["status"], &off["syncing"], &off["readOnly"]), (&json!("off"), &json!(false), &json!(false)));
|
||||
assert_eq!(off["peers"], json!([]));
|
||||
assert_eq!(off["sessions"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
(&off["sessions"][0]["session"], &off["sessions"][0]["document"], &off["sessions"][0]["link"]),
|
||||
(&a["session"], &json!(0), &a["link"])
|
||||
);
|
||||
assert_eq!((s.live().id, s.live().status.clone()), (0, Status::Off));
|
||||
assert!(s.live_mut().is_none() && s.active_read_only().is_none());
|
||||
assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], false, "nothing to stop here");
|
||||
assert_eq!(s.live_of(ua).status, Status::Live, "and the other file's session isn't stopped");
|
||||
// Hosting it too starts a second session; the first goes on.
|
||||
let b = run(&mut s, "collab.host", json!({}));
|
||||
assert_ne!(a["session"], b["session"]);
|
||||
assert_ne!(a["link"], b["link"]);
|
||||
assert_eq!((b["document"].clone(), b["sessions"].as_array().unwrap().len()), (json!(1), 2));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!((sc.hosted.len(), sc.left), (2, 0));
|
||||
}
|
||||
// People come and go in their own session.
|
||||
let (a_id, b_id) = (id_of(&a), id_of(&b));
|
||||
feed(&mut s, &script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]);
|
||||
feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]);
|
||||
assert_eq!((s.live_of(ua).peers.len(), s.live_of(ub).peers.len()), (1, 2));
|
||||
let names = |v: &Value| v["peers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
let p = s.live_summary();
|
||||
assert_eq!((names(&p), &p["link"]), (vec!["Bo".to_string(), "Cy".to_string()], &b["link"]));
|
||||
assert_eq!((&p["sessions"][0]["peers"], &p["sessions"][1]["peers"]), (&json!(1), &json!(2)));
|
||||
// The file in front decides what `collab.peers` and the commands are about.
|
||||
run(&mut s, "doc.switch", json!({"index": 0}));
|
||||
let p = run(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((names(&p), &p["link"], &p["document"], &p["session"]), (vec!["Ada".to_string()], &a["link"], &json!(0), &a["session"]));
|
||||
assert_eq!(run(&mut s, "collab.follow", json!({"client": 5}))["follow"], 5);
|
||||
assert!(s.execute("collab.follow", &json!({"client": 6})).is_err(), "Bo is in the other file's session");
|
||||
run(&mut s, "doc.switch", json!({"index": 1}));
|
||||
assert!(s.execute("collab.follow", &json!({"client": 5})).is_err());
|
||||
assert_eq!((s.live().follow, s.live_of(ua).follow), (None, Some(5)));
|
||||
// Stopping ends this file's session only.
|
||||
assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], true);
|
||||
assert_eq!(script.lock().unwrap().ended, [b_id]);
|
||||
assert_eq!((s.live().status.clone(), s.live_of(ua).status.clone()), (Status::Off, Status::Live));
|
||||
// Someone ending or leaving the other one doesn't bring this one back.
|
||||
feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]);
|
||||
assert_eq!((s.live.iter().count(), s.live_of(ua).peers.len()), (1, 1), "what a session still sent after it ended is dropped");
|
||||
// Closing a file ends its session.
|
||||
run(&mut s, "file.close", json!({"index": 0, "force": true}));
|
||||
s.live_sync();
|
||||
assert_eq!(script.lock().unwrap().ended, [b_id, a_id]);
|
||||
assert_eq!(s.live.iter().count(), 0);
|
||||
assert_eq!(s.message.as_deref(), Some("The live session's file was closed, so you left the session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_one_document_leaves_the_others_session_running() {
|
||||
let (mut s, script, [ua, ub], [a, b]) = two_hosted();
|
||||
run(&mut s, "file.close", json!({"index": 1, "force": true}));
|
||||
s.live_sync();
|
||||
assert_eq!(script.lock().unwrap().ended, [b]);
|
||||
assert_eq!((s.live_of(ua).status.clone(), s.live_of(ub).status.clone()), (Status::Live, Status::Off));
|
||||
assert_eq!((uid(&s), s.live().id), (ua, a), "the file left in front shows its own session");
|
||||
// It still syncs.
|
||||
script.lock().unwrap().msgs_to.clear();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
s.live_sync();
|
||||
assert_eq!(script.lock().unwrap().msgs_to, [a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_and_presence_stay_in_their_session() {
|
||||
let (mut s, script) = session();
|
||||
let (ua, a, ..) = host(&mut s, &script);
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let (ub, b, mut server, me) = host(&mut s, &script);
|
||||
s.live_sync();
|
||||
{
|
||||
let mut sc = script.lock().unwrap();
|
||||
sc.msgs_to.clear();
|
||||
sc.sent_to.clear();
|
||||
sc.sent.clear();
|
||||
}
|
||||
// An edit of the file in front goes to its session, as that file's own undo step.
|
||||
let rect = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [rect.0]}));
|
||||
s.live_sync();
|
||||
assert_eq!(script.lock().unwrap().msgs_to, [b]);
|
||||
assert_eq!(s.undo_depth().0, 1);
|
||||
assert!(!s.docs.iter().find(|d| d.uid == ua).unwrap().doc.contains(rect));
|
||||
let confirmed = relay(&script, &mut server, me);
|
||||
feed(&mut s, &script, b, confirmed);
|
||||
// Your selection and view go to that session; the other one isn't told anything new.
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!(sc.sent_to, [b]);
|
||||
assert_eq!(sc.sent[0].selection, Some(vec![rect]));
|
||||
}
|
||||
// With the other file in front: its session gets your presence there, this one gets none
|
||||
// (no cursor, selection or view of a file you don't look at).
|
||||
run(&mut s, "doc.switch", json!({"index": 0}));
|
||||
assert_eq!(s.undo_depth().0, 0, "undo is each file's own");
|
||||
run(&mut s, "collab.cursor", json!({"x": 3, "y": 4}));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let last = |id: u64| sc.sent_to.iter().zip(&sc.sent).rev().find(|(to, _)| **to == id).map(|(_, p)| p.clone()).unwrap();
|
||||
assert_eq!(last(b), Presence::default());
|
||||
assert_eq!((last(a).cursor.map(|c| (c.1, c.2)), last(a).selection), (Some((3.0, 4.0)), Some(vec![])));
|
||||
}
|
||||
assert_eq!((s.live().cursor, s.live_of(ub).cursor), (Some((3.0, 4.0)), None));
|
||||
// What others do in the file behind arrives there, and only there.
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "") else { panic!("no hello") };
|
||||
let (ada, outs) = server.join(h).unwrap();
|
||||
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
|
||||
let mut other = Client::join(w).unwrap();
|
||||
let mut doc = other.document().clone();
|
||||
doc.node_mut(rect).unwrap().name = "Theirs".into();
|
||||
let batch = other.edit(&doc).unwrap().unwrap();
|
||||
let remote: Vec<Event> =
|
||||
server.receive(ada, batch).into_iter().filter_map(|o| if let Outgoing::All(m) = o { Some(Event::Sync(m)) } else { None }).collect();
|
||||
let before = s.ds().unwrap().revision;
|
||||
feed(&mut s, &script, b, remote);
|
||||
assert_eq!(s.docs.iter().find(|d| d.uid == ub).unwrap().doc.node(rect).unwrap().name, "Theirs");
|
||||
assert_eq!(s.ds().unwrap().revision, before, "the file in front didn't change");
|
||||
assert_eq!(uid(&s), ua, "and stays in front");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_join_shows_where_it_started_until_its_file_opens() {
|
||||
let (mut s, script) = session();
|
||||
let first = uid(&s);
|
||||
let r = run(&mut s, "collab.join", json!({"link": LINK}));
|
||||
let join = id_of(&r);
|
||||
assert_eq!((&r["status"], &r["document"]), (&json!("connecting"), &Value::Null));
|
||||
assert_eq!(s.live().status, Status::Connecting);
|
||||
// Another file doesn't show it; the list of sessions does.
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let p = s.live_summary();
|
||||
assert_eq!((&p["status"], &p["sessions"][0]["status"], &p["sessions"][0]["document"]), (&json!("off"), &json!("connecting"), &Value::Null));
|
||||
// Its file opens as a tab of its own, in front, with the session.
|
||||
let mut theirs = figcraft_doc::Document::new("Team file");
|
||||
theirs.repair();
|
||||
let (_, me, welcome) = serve(&theirs);
|
||||
feed(&mut s, &script, join, vec![Event::Welcome { info: info(Role::Guest, me), peers: vec![peer(1, "Hana")] }, welcome]);
|
||||
assert_eq!((s.docs.len(), s.active), (3, 2));
|
||||
assert_eq!((s.doc().unwrap().name.as_str(), s.live().id, s.live().syncing()), ("Team file", join, true));
|
||||
assert_eq!(s.live_summary()["document"], 2);
|
||||
assert_eq!(s.live_of(first).status, Status::Off, "the tab it was started from is in no session");
|
||||
assert_eq!(s.live.iter().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_from_a_file_in_a_session_leaves_that_session_alone() {
|
||||
let (mut s, script) = session();
|
||||
let host = id_of(&run(&mut s, "collab.host", json!({})));
|
||||
let first = uid(&s);
|
||||
// A link that can't be used changes nothing.
|
||||
assert!(matches!(s.execute("collab.join", &json!({"link": "http://x"})), Err(EngineError::BadParams { .. })));
|
||||
assert_eq!((s.live.iter().count(), script.lock().unwrap().left), (1, 0));
|
||||
let join = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
||||
let p = run(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&p["status"], &p["role"], id_of(&p)), (&json!("live"), &json!("host"), host), "this file still shows its own session");
|
||||
assert_eq!(p["sessions"].as_array().unwrap().len(), 2);
|
||||
// The join fails: the reason is said, and the file's own session is as it was.
|
||||
feed(&mut s, &script, join, vec![Event::Status(Status::Failed { reason: "wrong session token".into() })]);
|
||||
assert_eq!(s.message.as_deref(), Some("wrong session token"));
|
||||
assert_eq!((s.live().status.clone(), s.live().role()), (Status::Live, Some(Role::Host)));
|
||||
let p = s.live_summary();
|
||||
assert_eq!((&p["sessions"][1]["status"], &p["sessions"][1]["reason"]), (&json!("failed"), &json!("wrong session token")));
|
||||
assert_eq!(script.lock().unwrap().ended, [join], "the link is told the failed session is over");
|
||||
// The next join from here takes the failed one's place.
|
||||
let again = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
||||
assert_eq!(s.live.iter().map(|l| l.id).collect::<Vec<_>>(), [host, again]);
|
||||
// From a file that isn't in a session, a failed join shows there, and nowhere else.
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let idle = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
||||
feed(&mut s, &script, idle, vec![Event::Status(Status::Failed { reason: "can't reach the host".into() })]);
|
||||
assert_eq!(s.live_summary()["reason"], "can't reach the host");
|
||||
assert_eq!(s.live_of(first).status, Status::Live);
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert_eq!(s.live_summary()["status"], "off", "a new file starts with no session");
|
||||
run(&mut s, "doc.switch", json!({"index": 1}));
|
||||
assert_eq!(run(&mut s, "collab.leave", json!({}))["ended"], false);
|
||||
assert_eq!(s.live_summary()["status"], "off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_join_outlives_the_tab_it_was_started_from() {
|
||||
let (mut s, script) = session();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let join = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
||||
let failed = id_of(&run(&mut s, "collab.join", json!({"link": LINK})));
|
||||
feed(&mut s, &script, failed, vec![Event::Status(Status::Failed { reason: "no".into() })]);
|
||||
run(&mut s, "file.close", json!({"index": 1, "force": true}));
|
||||
s.live_sync();
|
||||
// The one on its way goes on, shown on the file now in front; the failed one is gone.
|
||||
assert_eq!(s.live.iter().map(|l| l.id).collect::<Vec<_>>(), [join]);
|
||||
assert_eq!((s.live().id, s.live().status.clone()), (join, Status::Connecting));
|
||||
assert_eq!(script.lock().unwrap().ended, [failed, failed], "told when it failed and when its tab closed; never the join on its way");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishing_one_file_keeps_the_others_editable() {
|
||||
let (mut s, script) = session();
|
||||
let host = id_of(&run(&mut s, "collab.host", json!({})));
|
||||
let first = uid(&s);
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let r = run(&mut s, "collab.publish", json!({"server": "ws://127.0.0.1:47900/?key=k"}));
|
||||
assert_eq!((&r["status"], &r["readOnly"], &r["document"]), (&json!("connecting"), &json!(true), &json!(1)));
|
||||
assert_eq!(s.active_read_only(), Some(PUBLISHING));
|
||||
assert!(s.live_read_only(first).is_none());
|
||||
run(&mut s, "doc.switch", json!({"index": 0}));
|
||||
let p = s.live_summary();
|
||||
assert_eq!((&p["role"], &p["readOnly"], id_of(&p)), (&json!("host"), &json!(false), host));
|
||||
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
||||
// Publishing a file again replaces its own session, not the other file's.
|
||||
run(&mut s, "doc.switch", json!({"index": 1}));
|
||||
let again = id_of(&run(&mut s, "collab.publish", json!({"server": "ws://127.0.0.1:47900/?key=k"})));
|
||||
assert_eq!(script.lock().unwrap().ended, [id_of(&r)]);
|
||||
assert_eq!(s.live.iter().map(|l| l.id).collect::<Vec<_>>(), [host, again]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_and_spotlight_stay_in_their_session() {
|
||||
let (mut s, script, [ua, ub], [a, b]) = two_hosted();
|
||||
// Only the second file's host reads signals.
|
||||
feed(&mut s, &script, b, vec![Event::Signal(1, Signal::ready()), Event::PeerJoined(peer(3, "Ada"))]);
|
||||
script.lock().unwrap().msgs_to.clear();
|
||||
assert_eq!(run(&mut s, "collab.cursorChat", json!({"text": "here"}))["chat"], "here");
|
||||
assert_eq!(run(&mut s, "collab.spotlight", json!({"on": true}))["spotlighting"], true);
|
||||
assert_eq!(script.lock().unwrap().msgs_to, [b, b]);
|
||||
feed(&mut s, &script, b, vec![Event::Signal(3, Signal::chat("hello"))]);
|
||||
assert_eq!((s.live().chat.as_str(), s.live().spotlighting, s.live().chats.get(&3).map(String::as_str)), ("here", true, Some("hello")));
|
||||
// The first file's session has none of it, and can't send any.
|
||||
run(&mut s, "doc.switch", json!({"index": 0}));
|
||||
assert_eq!(uid(&s), ua);
|
||||
let p = s.live_summary();
|
||||
assert_eq!((&p["chat"], &p["spotlighting"], &p["signals"], id_of(&p)), (&json!(""), &json!(false), &json!(false), a));
|
||||
assert!(matches!(s.execute("collab.cursorChat", &json!({"text": "hi"})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(run(&mut s, "collab.cursorChat", json!({}))["open"], false);
|
||||
assert!(s.live_of(ub).spotlighting, "still spotlighting in the other file");
|
||||
// A report goes to the session of the file in front, which here isn't a server's.
|
||||
assert!(s.execute("collab.report", &json!({"reason": "spam"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_commands_survive_junk_with_several_sessions() {
|
||||
let junk = [
|
||||
json!(null),
|
||||
json!([1, 2]),
|
||||
json!({"port": 1e300, "bind": 5, "link": 7, "client": -3, "x": "NaN", "y": {}, "session": -1, "document": 1e300}),
|
||||
json!({"link": "", "client": 4_294_967_296u64, "session": "x", "document": {}}),
|
||||
json!({"text": 5, "on": "yes"}),
|
||||
json!({"text": "\u{0}\u{7f}".repeat(10_000), "on": null}),
|
||||
];
|
||||
for cmd in crate::all_commands().iter().filter(|c| c.id.starts_with("collab.")) {
|
||||
for j in &junk {
|
||||
let (mut s, script, _, [a, _]) = two_hosted();
|
||||
run(&mut s, "collab.join", json!({"link": LINK}));
|
||||
if let Err(EngineError::Internal { msg, .. }) = s.execute(cmd.id, j) {
|
||||
panic!("{} panicked on {j}: {msg}", cmd.id);
|
||||
}
|
||||
// Whatever it did, the sessions still take what arrives.
|
||||
feed(&mut s, &script, a, vec![Event::PeerJoined(peer(9, "Late")), Event::Status(Status::Off)]);
|
||||
s.live_sync();
|
||||
assert!(s.live.get(a).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Sharing with a join code against a scripted link: `collab.share` online and on the local
|
||||
//! network, the codes in `collab.peers`, switching what the code lets people do, stopping (online:
|
||||
//! the others' links go, the file and its owner stay; sharing again makes new ones), and
|
||||
//! `collab.join` with a code of either kind or a link (what typing tolerates, and what it says
|
||||
//! when it can't be a code).
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::code::{self, Local};
|
||||
use figcraft_collab::message::ClientMsg;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::live::{
|
||||
Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo, Shared, SharedLink, Status,
|
||||
join_target,
|
||||
};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// What the link was asked to do, and the events it hands out next (as the session started last).
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
hosted: Vec<HostOptions>,
|
||||
published: Vec<PublishOptions>,
|
||||
joined: Vec<String>,
|
||||
/// What the server was asked about a file's sharing: (server, file, owner, revoke, create).
|
||||
asked: Vec<(String, String, String, Vec<String>, bool)>,
|
||||
/// The server can't be reached.
|
||||
offline: bool,
|
||||
left: Vec<u64>,
|
||||
events: Vec<Event>,
|
||||
session: u64,
|
||||
}
|
||||
|
||||
struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
/// Where the scripted host listens, and its session's secret.
|
||||
const HOST: Local = Local { ip: Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
// As the network link does: a short secret when the session is shared with a code.
|
||||
let link = if opts.short { HOST.link() } else { "figcraft://join?host=192.168.1.20:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv".to_string() };
|
||||
s.hosted.push(opts);
|
||||
Ok(SessionInfo {
|
||||
role: Role::Host,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Shared".into(),
|
||||
pages: vec![],
|
||||
url: link.replace("figcraft://join?host=", "ws://"),
|
||||
link,
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
})
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
if !link.starts_with("figcraft://join?") {
|
||||
return Err("that's not a live session invite".into());
|
||||
}
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push(link.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, session: u64, opts: PublishOptions) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
if s.offline {
|
||||
return Err("no answer from the server".into());
|
||||
}
|
||||
s.asked.push((server.into(), file.into(), owner.into(), revoke.to_vec(), create));
|
||||
// Each time the file is shared the server makes new links, with new codes.
|
||||
let n = s.asked.iter().filter(|a| a.4).count();
|
||||
Ok(create.then(|| links(n, Some((&format!("NEWED1T0{n}"), &format!("NEWV1EW0{n}"))))))
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().left.push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
let session = s.session;
|
||||
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
let mut s = Session::new();
|
||||
let script = Arc::new(Mutex::new(Script::default()));
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
(s, script)
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn err(s: &mut Session, cmd: &str, p: Value) -> String {
|
||||
s.execute(cmd, &p).unwrap_err().to_string()
|
||||
}
|
||||
|
||||
/// The links a server makes for others the `n`th time a file is shared, with their codes (a
|
||||
/// server from before join codes has none).
|
||||
fn links(n: usize, codes: Option<(&str, &str)>) -> Shared {
|
||||
let link = |what: &str, code: Option<&str>| SharedLink {
|
||||
id: format!("{what}{n}"),
|
||||
link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"),
|
||||
code: code.map(str::to_string),
|
||||
};
|
||||
Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) }
|
||||
}
|
||||
|
||||
/// The file is on the server and joined: its owner's links, and what others join with (codes
|
||||
/// too, when given).
|
||||
fn on_the_server(s: &mut Session, script: &Arc<Mutex<Script>>, codes: Option<(&str, &str)>) {
|
||||
let link = |token: &str| format!("figcraft://join?server=wss://share.example:443/&file=f1&token={token}");
|
||||
let published = Published {
|
||||
server: "wss://share.example:443/".into(),
|
||||
file: "f1".into(),
|
||||
name: "Untitled".into(),
|
||||
edit: link("EditEditEditEditEdit00"),
|
||||
view: link("ViewViewViewViewView00"),
|
||||
owner: Some("OwnerOwnerOwnerOwner00".into()),
|
||||
expires: None,
|
||||
};
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Untitled".into(),
|
||||
pages: vec![],
|
||||
link: published.edit.clone(),
|
||||
url: String::new(),
|
||||
address: "wss://share.example:443/".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
};
|
||||
let events = vec![Event::Published(published), Event::Shared(Ok(links(0, codes))), Event::Welcome { info, peers: vec![] }];
|
||||
script.lock().unwrap().events.extend(events);
|
||||
s.live_poll();
|
||||
}
|
||||
|
||||
/// The file reached the server and its owner is in it, without links for others.
|
||||
fn on_the_server_unshared(s: &mut Session, script: &Arc<Mutex<Script>>) {
|
||||
on_the_server(s, script, None);
|
||||
if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) {
|
||||
sh.links = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() {
|
||||
let (mut s, _) = session();
|
||||
assert_eq!(s.prefs.publish_server, DEFAULT_SERVER);
|
||||
assert!(DEFAULT_SERVER.starts_with("wss://") && DEFAULT_SERVER.ends_with('/'), "TLS, and an address the preference itself accepts");
|
||||
assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": DEFAULT_SERVER}))["url"], DEFAULT_SERVER);
|
||||
// Another server; back to the built-in one; off.
|
||||
assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}))["url"], "wss://share.example/");
|
||||
let reset = ex(&mut s, "pref.publishServer", json!({}));
|
||||
assert_eq!((&reset["url"], &reset["default"]), (&json!(DEFAULT_SERVER), &json!(DEFAULT_SERVER)));
|
||||
assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": ""}))["url"], "");
|
||||
// Off is saved as off (an empty address isn't left out, or the default would come back).
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(saved["publish_server"], "");
|
||||
let loaded: crate::Prefs = serde_json::from_value(saved).unwrap();
|
||||
assert!(loaded.publish_server.is_empty());
|
||||
// Preferences saved before there was a default get it.
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert_eq!(old.publish_server, DEFAULT_SERVER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}));
|
||||
let r = ex(&mut s, "collab.share", json!({}));
|
||||
assert_eq!(
|
||||
(&r["status"], &r["share"]["via"], &r["share"]["access"], &r["code"]),
|
||||
(&json!("connecting"), &json!("online"), &json!("edit"), &Value::Null)
|
||||
);
|
||||
assert_eq!(r["readOnly"], true, "as for any publish, until the file is there");
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) };
|
||||
assert_eq!((p.server.as_str(), p.key.as_deref(), p.name.as_str(), p.share), ("wss://share.example/", None, "Untitled", true));
|
||||
assert!(sc.hosted.is_empty());
|
||||
}
|
||||
on_the_server(&mut s, &script, Some(("K7M2QX9TD", "4FHJ8R2WN")));
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["code"]), (&json!("live"), &json!("K7M-2QX-9TD")));
|
||||
assert_eq!(
|
||||
(&r["share"]["shared"], &r["share"]["editCode"], &r["share"]["viewCode"]),
|
||||
(&json!(true), &json!("K7M-2QX-9TD"), &json!("4FH-J8R-2WN"))
|
||||
);
|
||||
// What others get are links of their own, not the owner's.
|
||||
let (edit_link, own) = (r["share"]["editLink"].as_str().unwrap(), r["published"]["edit"].as_str().unwrap());
|
||||
assert!(edit_link.contains("ShareeditShareedit00") && edit_link != own && r["link"] == own, "{edit_link}");
|
||||
assert!(s.message.is_none(), "the popover shows the code: no toast about publishing");
|
||||
// Asking again only switches which code is the one to give out.
|
||||
let r = ex(&mut s, "collab.share", json!({"access": "view"}));
|
||||
assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view")));
|
||||
assert_eq!(ex(&mut s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts");
|
||||
assert_eq!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD");
|
||||
assert_eq!(script.lock().unwrap().published.len(), 1);
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
|
||||
// Stopping while the server can't be reached stops nothing, and says so.
|
||||
script.lock().unwrap().offline = true;
|
||||
assert!(err(&mut s, "collab.stop", json!({})).contains("couldn't stop sharing"));
|
||||
assert_eq!(ex(&mut s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD");
|
||||
script.lock().unwrap().offline = false;
|
||||
// Stopping: the server is asked, as the file's owner, to revoke the others' links. The file
|
||||
// stays there and its owner stays in it: nothing is left, nothing is removed.
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert!(sc.left.is_empty(), "the owner stays in the file");
|
||||
let want = (
|
||||
"wss://share.example/".to_string(),
|
||||
"f1".to_string(),
|
||||
"OwnerOwnerOwnerOwner00".to_string(),
|
||||
vec!["edit0".to_string(), "view0".to_string()],
|
||||
false,
|
||||
);
|
||||
assert_eq!(sc.asked, [want]);
|
||||
}
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["syncing"].is_boolean(), &r["code"]), (&json!("live"), &true, &Value::Null));
|
||||
assert_eq!(
|
||||
r["share"],
|
||||
json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "editLink": null, "viewLink": null})
|
||||
);
|
||||
assert!(r["published"]["owner"].is_string(), "still the owner's file on the server");
|
||||
assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server"));
|
||||
// Sharing again: new links and codes for the same file, nothing published twice.
|
||||
let r = ex(&mut s, "collab.share", json!({}));
|
||||
assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01")));
|
||||
assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01"));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!(sc.published.len(), 1);
|
||||
assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true));
|
||||
}
|
||||
assert_eq!(ex(&mut s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked");
|
||||
assert_eq!(script.lock().unwrap().asked.len(), 2);
|
||||
// Stopping again revokes the new ones; then `collab.stop` on a file that isn't shared leaves
|
||||
// its session like any other (the file stays on the server).
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
assert_eq!(script.lock().unwrap().asked[2].3, ["edit1", "view1"]);
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!((sc.left.clone(), sc.asked.len()), (vec![1], 3));
|
||||
}
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_without_join_codes_leaves_the_links() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "collab.share", json!({"via": "online", "server": "wss://old.example/?key=KeyKeyKeyKeyKeyKeyKey0", "name": "Poster"}));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!((sc.published[0].server.as_str(), sc.published[0].name.as_str()), ("wss://old.example/?key=KeyKeyKeyKeyKeyKeyKey0", "Poster"));
|
||||
}
|
||||
on_the_server(&mut s, &script, None);
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["code"], &r["share"]["editCode"]), (&json!("live"), &Value::Null, &Value::Null));
|
||||
assert!(r["share"]["editLink"].as_str().unwrap().contains("token="), "the links are what there is to share");
|
||||
assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary");
|
||||
// Codes that aren't codes are not shown either.
|
||||
script.lock().unwrap().events.push(Event::Shared(Ok(links(0, Some(("<script>", "K7M2QX9TDX"))))));
|
||||
s.live_poll();
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["share"]["editCode"], &r["share"]["viewCode"], &r["share"]["shared"]), (&Value::Null, &Value::Null, &json!(true)));
|
||||
// Stopping works there too (links are older than codes); the operator's key goes with the
|
||||
// address, since there is no owner token.
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({}))["shared"], false);
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!(sc.asked[0].0, "wss://old.example/?key=KeyKeyKeyKeyKeyKeyKey0");
|
||||
}
|
||||
// `collab.leave` keeps the file on the server: nothing is asked of it.
|
||||
ex(&mut s, "collab.leave", json!({}));
|
||||
assert_eq!(script.lock().unwrap().asked.len(), 1);
|
||||
// A server that couldn't make the links: the file is there, not shared, and it says why.
|
||||
ex(&mut s, "collab.share", json!({"server": "wss://full.example/"}));
|
||||
script.lock().unwrap().events.extend([Event::Shared(Err("a file has at most 100 links: revoke one first".into()))]);
|
||||
on_the_server_unshared(&mut s, &script);
|
||||
assert!(s.message.as_deref().unwrap_or_default().contains("couldn't be shared: a file has at most 100 links"));
|
||||
assert_eq!(ex(&mut s, "collab.peers", json!({}))["share"]["shared"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_sharing_is_off_without_a_server() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "pref.publishServer", json!({"url": ""}));
|
||||
assert!(matches!(s.execute("collab.share", &json!({})), Err(EngineError::Disabled(_, why)) if why.contains("online sharing is off")));
|
||||
assert!(err(&mut s, "collab.join", json!({"code": "K7M-2QX-9TD"})).contains("online sharing is off"));
|
||||
// The local network needs no server, and a server named for one share is enough.
|
||||
ex(&mut s, "collab.share", json!({"server": "wss://share.example/"}));
|
||||
assert_eq!(script.lock().unwrap().published[0].server, "wss://share.example/");
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
assert_eq!(ex(&mut s, "collab.share", json!({"via": "local"}))["status"], "live");
|
||||
// Without a link (the one-shot CLI) there is nothing to share with.
|
||||
let mut bare = Session::new();
|
||||
assert!(matches!(bare.execute("collab.share", &json!({"via": "local"})), Err(EngineError::Disabled(..))));
|
||||
assert!(matches!(bare.execute("collab.join", &json!({"code": HOST.code()})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
|
||||
let (mut s, script) = session();
|
||||
let r = ex(&mut s, "collab.share", json!({"via": "local"}));
|
||||
assert_eq!((&r["status"], &r["role"], &r["share"]["via"]), (&json!("live"), &json!("host"), &json!("local")));
|
||||
let shown = r["code"].as_str().unwrap();
|
||||
assert_eq!(code::parse(shown), Ok(code::Code::Local(HOST)), "{shown}");
|
||||
assert_eq!(shown, code::grouped(&HOST.code()));
|
||||
assert_eq!((&r["link"], &r["share"]["viewCode"]), (&json!(HOST.link()), &Value::Null));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [h] = sc.hosted.as_slice() else { panic!("hosted {}", sc.hosted.len()) };
|
||||
assert!(h.short && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
|
||||
}
|
||||
// Everyone edits there.
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "view"})).contains("share it online"));
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
|
||||
assert!(script.lock().unwrap().asked.is_empty(), "no server to tell");
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "view"})).contains("share it online"));
|
||||
// Where to listen is the advanced part.
|
||||
ex(&mut s, "collab.share", json!({"via": "local", "bind": "local", "port": 5000}));
|
||||
let sc = script.lock().unwrap();
|
||||
assert!(sc.hosted[1].bind == Bind::Local && sc.hosted[1].port == Some(5000));
|
||||
drop(sc);
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
for bad in [json!({"via": "post"}), json!({"via": "local", "port": 70000}), json!({"via": "local", "bind": "everywhere"})] {
|
||||
assert!(matches!(s.execute("collab.share", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
// A plain `collab.host` session has an invite link, not a code.
|
||||
let r = ex(&mut s, "collab.host", json!({}));
|
||||
assert_eq!((&r["code"], &r["share"]), (&Value::Null, &Value::Null));
|
||||
assert!(!script.lock().unwrap().hosted[2].short);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joining_takes_a_code_of_either_kind_or_a_link() {
|
||||
let (mut s, script) = session();
|
||||
ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}));
|
||||
let asks = "figcraft://join?server=wss://share.example/&code=K7M2QX9TD";
|
||||
let local = HOST.link();
|
||||
let link = "figcraft://join?host=10.0.0.5:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
let shown = code::grouped(&HOST.code());
|
||||
let table: [(Value, &str); 8] = [
|
||||
(json!({"code": "K7M-2QX-9TD"}), asks),
|
||||
(json!({"code": " k7m 2qx 9td \n"}), asks),
|
||||
(json!({"code": "k7m2qx9td"}), asks),
|
||||
(json!({"link": "K7M2QX9TD"}), asks),
|
||||
(json!({"code": shown}), &local),
|
||||
(json!({"code": shown.to_lowercase().replace('-', "")}), &local),
|
||||
(json!({"link": link}), link),
|
||||
// The one field takes either: a link where a code is expected.
|
||||
(json!({"code": format!(" {link} ")}), link),
|
||||
];
|
||||
for (params, want) in table {
|
||||
let r = ex(&mut s, "collab.join", params.clone());
|
||||
assert_eq!(r["status"], "connecting", "{params}");
|
||||
assert_eq!(script.lock().unwrap().joined.last().map(String::as_str), Some(want), "{params}");
|
||||
}
|
||||
// O for 0 and I or L for 1 are the same code.
|
||||
assert_eq!(join_target("wss://s.example/", "O1l-iLo-0oO"), join_target("wss://s.example/", "011-110-000"));
|
||||
// What can't be a code says why, and nothing is asked of the network.
|
||||
let before = script.lock().unwrap().joined.len();
|
||||
for (typed, why) in [
|
||||
("", "type the join code"),
|
||||
("K7M-2QX", "9 characters"),
|
||||
("K7M-2QX-9TDX", "that one has 10"),
|
||||
("K7M-2QU-9TD", "no U"),
|
||||
("hello world!", "only letters and digits"),
|
||||
(&shown.replace(&shown[..1], if shown.starts_with('Z') { "Y" } else { "Z" }), "typing mistake"),
|
||||
] {
|
||||
let e = err(&mut s, "collab.join", json!({"code": typed}));
|
||||
assert!(e.contains(why), "{typed:?}: {e}");
|
||||
}
|
||||
assert!(err(&mut s, "collab.join", json!({})).contains("give the join `code`"));
|
||||
assert!(err(&mut s, "collab.join", json!({"code": 7})).contains("9 characters"));
|
||||
assert!(s.execute("collab.join", &json!({"code": {"x": 1}})).is_err());
|
||||
// Something that looks like a link goes to the link's reader.
|
||||
assert!(err(&mut s, "collab.join", json!({"code": "https://example.com/x"})).contains("not a live session invite"));
|
||||
assert_eq!(script.lock().unwrap().joined.len(), before);
|
||||
// A failed lookup shows on the tab it was started from (the link's event).
|
||||
ex(&mut s, "collab.join", json!({"code": "K7M-2QX-9TD"}));
|
||||
script.lock().unwrap().events.push(Event::Status(Status::Failed { reason: "No session with that code".into() }));
|
||||
s.live_poll();
|
||||
let r = ex(&mut s, "collab.peers", json!({}));
|
||||
assert_eq!((&r["status"], &r["reason"]), (&json!("failed"), &json!("No session with that code")));
|
||||
assert_eq!(s.message.as_deref(), Some("No session with that code"));
|
||||
}
|
||||
@@ -46,10 +46,10 @@ fn chat_needs_a_session_whose_host_reads_it() {
|
||||
// Someone else's "ready" isn't the host speaking to you.
|
||||
push(&script, vec![Event::Signal(1, Signal::ready())]);
|
||||
s.live_poll();
|
||||
assert!(!s.live.signals);
|
||||
assert!(!s.live().signals);
|
||||
push(&script, vec![Event::Signal(2, Signal::ready())]);
|
||||
s.live_poll();
|
||||
assert!(s.live.signals);
|
||||
assert!(s.live().signals);
|
||||
assert_eq!(s.live_summary()["signals"], true);
|
||||
}
|
||||
|
||||
@@ -86,15 +86,15 @@ fn others_chat_shows_until_closed_or_they_leave() {
|
||||
],
|
||||
);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live.chats.get(&1).map(String::as_str), Some("hey"));
|
||||
assert_eq!(s.live.chats.get(&3).map(String::as_str), Some("bad [2J"), "control characters never reach the canvas");
|
||||
assert_eq!(s.live.chats.len(), 2);
|
||||
assert_eq!(s.live.spotlight, None);
|
||||
assert_eq!(s.live().chats.get(&1).map(String::as_str), Some("hey"));
|
||||
assert_eq!(s.live().chats.get(&3).map(String::as_str), Some("bad [2J"), "control characters never reach the canvas");
|
||||
assert_eq!(s.live().chats.len(), 2);
|
||||
assert_eq!(s.live().spotlight, None);
|
||||
let peers = s.live_summary()["peers"].clone();
|
||||
assert_eq!(peers[0]["chat"], "hey");
|
||||
push(&script, vec![Event::Signal(1, Signal::chat("")), Event::PeerLeft(3)]);
|
||||
s.live_poll();
|
||||
assert!(s.live.chats.is_empty());
|
||||
assert!(s.live().chats.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -102,29 +102,29 @@ fn a_spotlight_makes_you_follow_until_you_move() {
|
||||
let (mut s, script) = joined(true);
|
||||
push(&script, vec![Event::Signal(1, Signal::spotlight(true)), Event::Presence(1, Presence::default())]);
|
||||
s.live_poll();
|
||||
assert_eq!((s.live.spotlight, s.live.follow), (Some(1), Some(1)));
|
||||
assert_eq!((s.live().spotlight, s.live().follow), (Some(1), Some(1)));
|
||||
assert!(s.message.take().unwrap().contains("Hana is spotlighting"));
|
||||
// Moving your view stops following; the spotlight goes on (the banner offers to follow again).
|
||||
push(&script, vec![Event::Presence(1, Presence { viewport: Some((s.page().unwrap(), 0.0, 0.0, 600.0, 400.0)), ..Default::default() })]);
|
||||
s.live_poll();
|
||||
s.ds_mut().unwrap().view.cx += 50.0;
|
||||
s.live_sync();
|
||||
assert_eq!((s.live.spotlight, s.live.follow), (Some(1), None));
|
||||
assert_eq!((s.live().spotlight, s.live().follow), (Some(1), None));
|
||||
s.execute("collab.follow", &json!({"client": 1})).unwrap();
|
||||
// Ada takes over; then she stops and nobody is followed.
|
||||
push(&script, vec![Event::Signal(3, Signal::spotlight(true))]);
|
||||
s.live_poll();
|
||||
assert_eq!((s.live.spotlight, s.live.follow), (Some(3), Some(3)));
|
||||
assert_eq!((s.live().spotlight, s.live().follow), (Some(3), Some(3)));
|
||||
push(&script, vec![Event::Signal(1, Signal::spotlight(false))]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live.spotlight, Some(3), "an old presenter stopping changes nothing");
|
||||
assert_eq!(s.live().spotlight, Some(3), "an old presenter stopping changes nothing");
|
||||
push(&script, vec![Event::Signal(3, Signal::spotlight(false))]);
|
||||
s.live_poll();
|
||||
assert_eq!((s.live.spotlight, s.live.follow), (None, None));
|
||||
assert_eq!((s.live().spotlight, s.live().follow), (None, None));
|
||||
// The presenter leaving ends it too.
|
||||
push(&script, vec![Event::Signal(1, Signal::spotlight(true)), Event::PeerLeft(1)]);
|
||||
s.live_poll();
|
||||
assert_eq!((s.live.spotlight, s.live.follow), (None, None));
|
||||
assert_eq!((s.live().spotlight, s.live().follow), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -132,20 +132,20 @@ fn spotlighting_yourself() {
|
||||
let (mut s, script) = joined(true);
|
||||
s.execute("collab.follow", &json!({"client": 1})).unwrap();
|
||||
assert_eq!(s.execute("collab.spotlight", &json!({})).unwrap()["spotlighting"], true);
|
||||
assert_eq!(s.live.follow, None, "a presenter follows nobody");
|
||||
assert_eq!(s.live().follow, None, "a presenter follows nobody");
|
||||
s.execute("collab.spotlight", &json!({"on": true})).unwrap();
|
||||
assert_eq!(sent(&script), vec![Signal::spotlight(true)], "on twice is sent once");
|
||||
assert!(s.execute("collab.spotlight", &json!({"on": "yes"})).is_err());
|
||||
// Someone else's spotlight takes over yours.
|
||||
push(&script, vec![Event::Signal(3, Signal::spotlight(true))]);
|
||||
s.live_poll();
|
||||
assert!(!s.live.spotlighting);
|
||||
assert_eq!(s.live.follow, Some(3));
|
||||
assert!(!s.live().spotlighting);
|
||||
assert_eq!(s.live().follow, Some(3));
|
||||
s.execute("collab.spotlight", &json!({"on": true})).unwrap();
|
||||
// A reconnect: the host hears it again once it reads signals again.
|
||||
push(&script, vec![Event::Status(crate::live::Status::Reconnecting { attempt: 1 })]);
|
||||
s.live_poll();
|
||||
assert!(!s.live.signals && s.live.spotlighting);
|
||||
assert!(!s.live().signals && s.live().spotlighting);
|
||||
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana")] }, Event::Signal(2, Signal::ready())]);
|
||||
s.live_poll();
|
||||
assert_eq!(sent(&script).last(), Some(&Signal::spotlight(true)));
|
||||
@@ -154,5 +154,5 @@ fn spotlighting_yourself() {
|
||||
assert_eq!(sent(&script).last(), Some(&Signal::spotlight(false)));
|
||||
// Leaving forgets it all.
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
assert!(!s.live.spotlighting && !s.live.signals && s.live.chat.is_empty());
|
||||
assert!(!s.live().spotlighting && !s.live().signals && s.live().chat.is_empty());
|
||||
}
|
||||
@@ -15,46 +15,72 @@ use crate::{EngineError, Session};
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Script {
|
||||
pub(crate) events: Vec<Event>,
|
||||
sent: Vec<Presence>,
|
||||
hosted: Vec<HostOptions>,
|
||||
pub(crate) sent: Vec<Presence>,
|
||||
pub(crate) hosted: Vec<HostOptions>,
|
||||
joined: Vec<String>,
|
||||
left: usize,
|
||||
pub(crate) left: usize,
|
||||
pub(crate) msgs: Vec<ClientMsg>,
|
||||
pub(crate) published: Vec<PublishOptions>,
|
||||
/// The session started last: the scripted events are its.
|
||||
pub(crate) session: u64,
|
||||
/// The sessions ended, in order.
|
||||
pub(crate) ended: Vec<u64>,
|
||||
/// The session each presence in `sent` and each message in `msgs` was for.
|
||||
pub(crate) sent_to: Vec<u64>,
|
||||
pub(crate) msgs_to: Vec<u64>,
|
||||
}
|
||||
|
||||
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
|
||||
|
||||
impl LiveLink for Mock {
|
||||
fn host(&mut self, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
self.0.lock().unwrap().hosted.push(opts);
|
||||
Ok(info(Role::Host, 1))
|
||||
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.hosted.push(opts);
|
||||
// Each session listens on its own port (47900 for the first).
|
||||
let mut i = info(Role::Host, 1);
|
||||
i.address = format!("127.0.0.1:{}", 47_899 + session);
|
||||
i.link = format!("figcraft://join?host={}&file=f&token=t", i.address);
|
||||
i.url = format!("ws://{}/?file=f&token=t", i.address);
|
||||
Ok(i)
|
||||
}
|
||||
fn join(&mut self, link: &str, _: &str) -> Result<(), String> {
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
if !link.starts_with("figcraft://") {
|
||||
return Err("not an invite".into());
|
||||
}
|
||||
self.0.lock().unwrap().joined.push(link.to_string());
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.joined.push(link.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, opts: PublishOptions) -> Result<(), String> {
|
||||
fn publish(&mut self, session: u64, opts: PublishOptions) -> Result<(), String> {
|
||||
if !opts.server.starts_with("ws") {
|
||||
return Err("not a server address".into());
|
||||
}
|
||||
self.0.lock().unwrap().published.push(opts);
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
s.published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self) {
|
||||
self.0.lock().unwrap().left += 1;
|
||||
fn leave(&mut self, session: u64) {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.left += 1;
|
||||
s.ended.push(session);
|
||||
}
|
||||
fn presence(&mut self, p: &Presence) {
|
||||
self.0.lock().unwrap().sent.push(p.clone());
|
||||
fn presence(&mut self, session: u64, p: &Presence) {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.sent_to.push(session);
|
||||
s.sent.push(p.clone());
|
||||
}
|
||||
fn send(&mut self, msg: ClientMsg) {
|
||||
self.0.lock().unwrap().msgs.push(msg);
|
||||
fn send(&mut self, session: u64, msg: ClientMsg) {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.msgs_to.push(session);
|
||||
s.msgs.push(msg);
|
||||
}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.0.lock().unwrap().events)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +100,7 @@ pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn peer(id: u32, name: &str) -> Peer {
|
||||
pub(crate) fn peer(id: u32, name: &str) -> Peer {
|
||||
Peer { client_id: id, name: name.into(), color: "#9747FF".into() }
|
||||
}
|
||||
|
||||
@@ -90,7 +116,7 @@ pub(crate) fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
|
||||
}
|
||||
|
||||
/// A session server for `doc` and the welcome it gives a first client (as `Event::Sync`).
|
||||
fn serve(doc: &Document) -> (Server, u32, Event) {
|
||||
pub(crate) fn serve(doc: &Document) -> (Server, u32, Event) {
|
||||
serve_as(doc, Access::Edit)
|
||||
}
|
||||
|
||||
@@ -159,9 +185,15 @@ fn hosting_and_joining_drive_the_link() {
|
||||
let r = s.execute("collab.join", &json!({"link": " figcraft://join?host=a:1&file=f&token=t "})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
assert_eq!(script.lock().unwrap().joined, ["figcraft://join?host=a:1&file=f&token=t"]);
|
||||
// The join is a session of its own: this file's goes on until it is stopped, and then the
|
||||
// join still on its way shows here.
|
||||
assert_eq!(s.live().role(), Some(Role::Host));
|
||||
assert_eq!(s.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
assert!(script.lock().unwrap().left >= 1);
|
||||
assert_eq!(s.live.status, Status::Off);
|
||||
assert_eq!(s.live().status, Status::Connecting);
|
||||
assert_eq!(s.execute("collab.leave", &json!({})).unwrap()["ended"], true);
|
||||
assert_eq!(s.live().status, Status::Off);
|
||||
assert_eq!(s.live.iter().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -170,16 +202,16 @@ fn events_build_who_is_here() {
|
||||
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
|
||||
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana")] }, Event::PeerJoined(peer(3, "Bo"))]);
|
||||
assert!(s.live_poll());
|
||||
assert_eq!(s.live.status, Status::Live);
|
||||
assert_eq!(s.live.peers.len(), 2);
|
||||
assert_eq!(s.live().status, Status::Live);
|
||||
assert_eq!(s.live().peers.len(), 2);
|
||||
assert_eq!(s.message.as_deref(), Some("Bo joined"));
|
||||
// Presence of someone not in the session is ignored; a leaver's presence goes with them.
|
||||
push(&script, vec![Event::Presence(9, viewport(NodeId(1), 0.0, 0.0, 10.0, 10.0)), Event::Presence(3, viewport(NodeId(1), 0.0, 0.0, 10.0, 10.0))]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live.presence.keys().copied().collect::<Vec<_>>(), [3]);
|
||||
assert_eq!(s.live().presence.keys().copied().collect::<Vec<_>>(), [3]);
|
||||
push(&script, vec![Event::PeerLeft(3)]);
|
||||
s.live_poll();
|
||||
assert!(s.live.presence.is_empty());
|
||||
assert!(s.live().presence.is_empty());
|
||||
assert_eq!(s.message.as_deref(), Some("Bo left"));
|
||||
let p = s.live_summary();
|
||||
assert_eq!(p["peers"][0]["name"], "Hana");
|
||||
@@ -187,11 +219,11 @@ fn events_build_who_is_here() {
|
||||
// Reconnecting forgets who's here until the next welcome; failing ends it all.
|
||||
push(&script, vec![Event::Status(Status::Reconnecting { attempt: 1 })]);
|
||||
s.live_poll();
|
||||
assert!(s.live.peers.is_empty());
|
||||
assert!(s.live().peers.is_empty());
|
||||
assert_eq!(s.live_summary()["attempt"], 1);
|
||||
push(&script, vec![Event::Status(Status::Failed { reason: "Lost the connection".into() })]);
|
||||
s.live_poll();
|
||||
assert!(s.live.info.is_none());
|
||||
assert!(s.live().info.is_none());
|
||||
assert_eq!(s.live_summary()["reason"], "Lost the connection");
|
||||
assert_eq!(s.message.as_deref(), Some("Lost the connection"));
|
||||
assert!(!s.live_poll(), "nothing new");
|
||||
@@ -200,10 +232,11 @@ fn events_build_who_is_here() {
|
||||
#[test]
|
||||
fn presence_is_sent_when_it_changes_and_only_when_live() {
|
||||
let (mut s, script) = session();
|
||||
s.live.cursor = Some((10.0, 20.0));
|
||||
s.live_sync();
|
||||
assert!(script.lock().unwrap().sent.is_empty(), "not in a session");
|
||||
assert!(s.live_mut().is_none(), "nothing to set a pointer in");
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
s.live_mut().unwrap().cursor = Some((10.0, 20.0));
|
||||
s.live_sync();
|
||||
s.live_sync();
|
||||
let sent = script.lock().unwrap().sent.clone();
|
||||
@@ -218,11 +251,11 @@ fn presence_is_sent_when_it_changes_and_only_when_live() {
|
||||
s.execute("collab.cursor", &json!({"x": 1, "y": 2})).unwrap();
|
||||
assert_eq!(script.lock().unwrap().sent.last().unwrap().cursor, Some((page, 1.0, 2.0)));
|
||||
assert!(s.execute("collab.cursor", &json!({"x": 1})).is_err());
|
||||
s.live.cursor = Some((f64::NAN, 0.0));
|
||||
s.live_mut().unwrap().cursor = Some((f64::NAN, 0.0));
|
||||
s.live_sync();
|
||||
assert_eq!(script.lock().unwrap().sent.last().unwrap().cursor, None);
|
||||
s.execute("collab.cursor", &json!({})).unwrap();
|
||||
assert_eq!(s.live.cursor, None);
|
||||
assert_eq!(s.live().cursor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -256,12 +289,12 @@ fn following_shows_their_viewport_and_page() {
|
||||
// Zooming yourself stops following.
|
||||
s.ds_mut().unwrap().view.zoom = 3.0;
|
||||
s.live_sync();
|
||||
assert_eq!(s.live.follow, None);
|
||||
assert_eq!(s.live().follow, None);
|
||||
// So does Escape, before anything else it does.
|
||||
s.execute("collab.follow", &json!({"client": 5})).unwrap();
|
||||
s.execute("select.all", &json!({})).ok();
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.live.follow, None);
|
||||
assert_eq!(s.live().follow, None);
|
||||
// And `collab.follow {}`.
|
||||
s.execute("collab.follow", &json!({"client": 5})).unwrap();
|
||||
assert_eq!(s.execute("collab.follow", &json!({})).unwrap()["follow"], serde_json::Value::Null);
|
||||
@@ -276,8 +309,8 @@ fn the_host_ending_resets_everything() {
|
||||
s.execute("collab.follow", &json!({"client": 1})).unwrap();
|
||||
push(&script, vec![Event::Ended { reason: "The host ended the session".into() }]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live.status, Status::Off);
|
||||
assert!(s.live.peers.is_empty() && s.live.follow.is_none() && s.live.info.is_none());
|
||||
assert_eq!(s.live().status, Status::Off);
|
||||
assert!(s.live().peers.is_empty() && s.live().follow.is_none() && s.live().info.is_none());
|
||||
assert_eq!(s.message.as_deref(), Some("The host ended the session"));
|
||||
}
|
||||
|
||||
@@ -308,7 +341,7 @@ fn edits_sync_through_the_server_and_remote_ones_skip_undo() {
|
||||
let (mut server, me, welcome) = serve(s.doc().unwrap());
|
||||
push(&script, vec![welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live.syncing() && s.live_shared());
|
||||
assert!(s.live().syncing() && s.live_shared());
|
||||
assert!(!s.ds().unwrap().is_dirty(), "starting a session doesn't dirty the file");
|
||||
// An edit made while hosting goes out as one batch, and the server confirms it.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
|
||||
@@ -372,12 +405,13 @@ fn edits_sync_through_the_server_and_remote_ones_skip_undo() {
|
||||
s.live_sync();
|
||||
assert_eq!(s.doc().unwrap().node(rect).unwrap().name, "Card");
|
||||
// Closing the session's file leaves the session.
|
||||
let uid = s.live().doc_uid.unwrap();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let uid = s.live.doc_uid.unwrap();
|
||||
let i = s.docs.iter().position(|d| d.uid == uid).unwrap();
|
||||
s.execute("file.close", &json!({"index": i, "force": true})).unwrap();
|
||||
s.live_sync();
|
||||
assert_eq!(s.live.status, Status::Off);
|
||||
assert_eq!(s.live_of(uid).status, Status::Off);
|
||||
assert_eq!(s.live.iter().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -409,3 +443,24 @@ fn a_guest_opens_the_hosts_file_as_a_new_tab() {
|
||||
assert_eq!(s.docs.len(), 2);
|
||||
assert!(s.doc().unwrap().contains(NodeId(r["id"].as_u64().unwrap())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_commands_check_what_they_are_given() {
|
||||
let mut s = Session::new();
|
||||
// The publish server preference: a WebSocket address without a key, or nothing.
|
||||
assert_eq!(s.execute("pref.publishServer", &json!({"url": " wss://figcraft.example.com/ "})).unwrap()["url"], "wss://figcraft.example.com/");
|
||||
for bad in ["https://figcraft.example.com/", "wss://host/?key=secret", "wss://a b/"] {
|
||||
assert!(s.execute("pref.publishServer", &json!({"url": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.prefs.publish_server, "wss://figcraft.example.com/", "a refused address changes nothing");
|
||||
s.execute("pref.publishServer", &json!({"url": ""})).unwrap();
|
||||
assert!(s.prefs.publish_server.is_empty());
|
||||
// No network here: the server can't be asked, nothing can be reported.
|
||||
let e = s.execute("collab.serverInfo", &json!({"server": "wss://figcraft.example.com/"})).unwrap_err().to_string();
|
||||
assert!(e.contains("desktop app or the MCP server"), "{e}");
|
||||
let e = s.execute("collab.manageFile", &json!({"server": "wss://x/", "file": "f", "owner": "o", "action": "explode"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`action` must be"), "{e}");
|
||||
let e = s.execute("collab.report", &json!({"reason": "Spam"})).unwrap_err().to_string();
|
||||
assert!(e.contains("join a file"), "{e}");
|
||||
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
|
||||
}
|
||||
@@ -28,16 +28,16 @@ struct Dir {
|
||||
struct Link(Arc<Mutex<Dir>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
fn join(&mut self, _: &str, _: &str) -> Result<(), String> {
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Err("no".into())
|
||||
}
|
||||
fn leave(&mut self) {}
|
||||
fn presence(&mut self, _: &Presence) {}
|
||||
fn send(&mut self, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
Vec::new()
|
||||
}
|
||||
fn plugins(&mut self, server: &str, req: PluginReq) -> Result<PluginReply, String> {
|
||||
|
||||
@@ -272,6 +272,13 @@ fn every_command_survives_junk_params() {
|
||||
// Keyboard steps (view.pan) and toggles as junk.
|
||||
json!({"dx": 1e308, "dy": "NaN", "big": "yes", "on": 3}),
|
||||
json!({"dx": -1e308, "dy": [1], "big": null, "on": null}),
|
||||
// Open hosting: servers, owner tokens, actions, reports and the publish server as junk.
|
||||
json!({"server": "wss://\u{0}", "file": [1], "owner": {"x": 1}, "action": "remove", "reason": 5, "link": -1, "access": "admin", "url": 7}),
|
||||
json!({"server": "ws://127.0.0.1:1/", "file": "../x", "owner": "", "action": "createLink", "reason": "\u{7}\u{0}", "name": "", "url": "ws://x/?key=k"}),
|
||||
// Sharing with a join code: ways, access, codes, servers, ports and interfaces as junk.
|
||||
json!({"via": 7, "access": [1], "code": {"x": 1}, "link": -1, "server": 5, "port": -1, "bind": 3, "name": [1]}),
|
||||
json!({"via": "carrier pigeon", "access": "admin", "code": "K7M-2QX-9T\u{0}", "server": "wss://\u{0}", "port": 1e308, "bind": "::::"}),
|
||||
json!({"via": "local", "access": "view", "code": "\u{1F4A5}".repeat(64), "port": 70000, "bind": "lan"}),
|
||||
// Rotation origins and placing with a tool as junk.
|
||||
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
|
||||
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Text box sizes measured against documents re-saved by other software. Local only: runs when
|
||||
//! `FIGCRAFT_FIG_SAMPLES` names a folder of `.fig` files, and only for text whose exact font is
|
||||
//! installed. The rules themselves are tested on built-in fonts in `figcraft_text`.
|
||||
|
||||
use figcraft_doc::{LineHeight, Node, NodeKind, TextAutoResize};
|
||||
|
||||
/// `n` with its percent line height read as a share of the font's own line height (the import
|
||||
/// reads every percent as a share of the font size; files can mean either).
|
||||
fn of_own_line_height(n: &Node) -> Option<Node> {
|
||||
let mut m = n.clone();
|
||||
let NodeKind::Text(t) = &mut m.kind else { return None };
|
||||
let LineHeight::Percent(p) = t.style.line_height else { return None };
|
||||
let f = figcraft_text::find_face(&t.style.font_family, &t.style.font_style)?;
|
||||
t.style.line_height = LineHeight::Percent(p * (f.ascent + f.descent + f.line_gap.max(0.0)) / f.upem);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Each text box that sizes itself comes out at the size stored in the file, to the pixel.
|
||||
#[test]
|
||||
fn local_samples_keep_their_text_box_sizes() {
|
||||
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return };
|
||||
let Ok(dir) = std::fs::read_dir(dir) else { return };
|
||||
figcraft_text::scan_system_fonts();
|
||||
for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")) {
|
||||
let Ok((doc, _)) = figcraft_format::fig::import(&std::fs::read(&p).unwrap(), "sample") else { continue };
|
||||
for n in doc.nodes() {
|
||||
let NodeKind::Text(t) = &n.kind else { continue };
|
||||
// Stored sizes are whole pixels once the text was laid out by the software that saved it.
|
||||
let laid_out = n.height.fract() == 0.0 && (t.auto_resize != TextAutoResize::WidthAndHeight || n.width.fract() == 0.0);
|
||||
let own_font = t.spans().iter().all(|(_, _, s)| {
|
||||
figcraft_text::find_face(&s.font_family, &s.font_style).is_some_and(|f| f.family == s.font_family && f.style == s.font_style)
|
||||
});
|
||||
let Some((w, h)) = figcraft_text::auto_size(n).filter(|_| laid_out && own_font) else { continue };
|
||||
let h = if h == n.height { h } else { of_own_line_height(n).and_then(|m| figcraft_text::auto_size(&m)).map_or(h, |s| s.1) };
|
||||
assert_eq!((w, h), (n.width, n.height), "{}: {:?}", p.display(), t.characters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -582,11 +582,16 @@ mod tests {
|
||||
}
|
||||
let y = l.lines[0].top + l.lines[0].height / 2.0;
|
||||
let x_of = |i: usize| l.caret(i).0;
|
||||
// Double-click inside "world" edits and selects the word.
|
||||
// Double-click inside "world" edits with all the text selected; another one there
|
||||
// selects the word.
|
||||
let mid = (x_of(7) + x_of(8)) / 2.0;
|
||||
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (0, 11));
|
||||
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
|
||||
assert_eq!(sel(&s), (6, 11));
|
||||
// Drag-select from the start of "Hello" to the middle.
|
||||
s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap();
|
||||
|
||||
@@ -174,6 +174,11 @@ pub struct PointerEvent {
|
||||
pub pressure: Option<f32>,
|
||||
}
|
||||
|
||||
/// The pointer is still within [`DRAG_SLOP_PX`] of where it was pressed.
|
||||
fn within_slop(ev: &PointerEvent, start: Point) -> bool {
|
||||
(ev.p() - start).hypot() * ev.zoom.max(1e-6) < DRAG_SLOP_PX
|
||||
}
|
||||
|
||||
impl PointerEvent {
|
||||
pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self {
|
||||
Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom, pressure: None }
|
||||
@@ -300,6 +305,7 @@ pub(crate) enum Gesture {
|
||||
start: Point,
|
||||
r: Box<ResizeStart>,
|
||||
scale_tool: bool,
|
||||
moved: bool,
|
||||
},
|
||||
Rotate {
|
||||
center: Point,
|
||||
@@ -498,6 +504,9 @@ const HANDLE_PX: f64 = 5.0;
|
||||
const ROTATE_PX: f64 = 18.0;
|
||||
/// Snap distance in screen pixels.
|
||||
pub(crate) const SNAP_PX: f64 = 5.0;
|
||||
/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a
|
||||
/// double-click made with an unsteady hand doesn't drag what it lands on.
|
||||
const DRAG_SLOP_PX: f64 = 3.0;
|
||||
|
||||
/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl).
|
||||
pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> {
|
||||
@@ -1018,6 +1027,7 @@ impl Session {
|
||||
start: ev.p(),
|
||||
r: Box::new(ResizeStart { nodes, frame, single, handle: h }),
|
||||
scale_tool: self.tool.tool == ToolId::Scale,
|
||||
moved: false,
|
||||
};
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1091,6 +1101,7 @@ impl Session {
|
||||
self.set_selection(sel);
|
||||
Ok(())
|
||||
}
|
||||
Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()),
|
||||
Gesture::Move { start, ids, targets, duplicate, .. } => {
|
||||
let mut d = ev.p() - *start;
|
||||
if ev.shift {
|
||||
@@ -1208,7 +1219,11 @@ impl Session {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Gesture::Resize { start, r, scale_tool } => self.resize_drag(ev, *start, r, *scale_tool),
|
||||
Gesture::Resize { start, r, scale_tool, .. } => {
|
||||
let res = self.resize_drag(ev, *start, r, *scale_tool);
|
||||
self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true };
|
||||
return res;
|
||||
}
|
||||
Gesture::Rotate { center, start_angle, ids } => {
|
||||
let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees();
|
||||
if ev.shift {
|
||||
@@ -1680,15 +1695,26 @@ impl Session {
|
||||
self.widget_click(ev.p(), ev.tol() * 2.0)?;
|
||||
return Ok(());
|
||||
}
|
||||
// The second press began a move or a resize: a double-click isn't one.
|
||||
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) {
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
|
||||
let target = select_target(&doc, leaf, &sel, true);
|
||||
// Text: edit at the click.
|
||||
if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
|
||||
&& (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target)
|
||||
{
|
||||
// Entering a text selects all of it; in the text being edited, the word.
|
||||
let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target);
|
||||
let idx = self.text_index_at(target, ev.p())?;
|
||||
textedit::begin(self, target, Some(idx), false)?;
|
||||
textedit::select_word(self, idx)?;
|
||||
if editing {
|
||||
textedit::select_word(self, idx)?;
|
||||
} else {
|
||||
textedit::key(self, "SelectAll", false, false, false)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active).
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//! among the siblings, then its fields), parents first, plus blobs (vector networks and outlines).
|
||||
//! Instances are written as their main and the fields their layers override (by id path), like
|
||||
//! the files the import reads. What the format can't carry yet (shared styles, variables, Dev Mode
|
||||
//! data, …) is left out and listed in the warnings. Only round trips through our own importer are
|
||||
//! verified; other apps may not open every file.
|
||||
//! data, …) is left out and listed in the warnings. Round trips through our own importer are
|
||||
//! tested, and written files were opened in other software (`docs/file-format-parity.md` says what
|
||||
//! was checked); other apps may not open every file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
@@ -52,100 +53,107 @@ const VERSION: u32 = 101;
|
||||
const LAYERS: u32 = 1;
|
||||
const AUX: u32 = 2;
|
||||
|
||||
/// The schema written into every file: `enum Name A B …` (values from 0), `struct|message Name
|
||||
/// type field, …` (field ids from 1). Only what the export writes.
|
||||
/// The schema written into every file: `enum Name A=value …`, `struct Name type field, …` (every
|
||||
/// field, in order) and `message Name type field=id, …`, with the names, kinds and numbers the
|
||||
/// format gives them. Only what the export writes.
|
||||
pub(super) const SCHEMA: &str = "
|
||||
enum MessageType JOIN_START NODE_CHANGES
|
||||
enum NodePhase CREATED REMOVED
|
||||
enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE SECTION
|
||||
enum BlendMode PASS_THROUGH NORMAL DARKEN MULTIPLY LINEAR_BURN COLOR_BURN LIGHTEN SCREEN LINEAR_DODGE COLOR_DODGE OVERLAY SOFT_LIGHT HARD_LIGHT DIFFERENCE EXCLUSION HUE SATURATION COLOR LUMINOSITY
|
||||
enum PaintType SOLID GRADIENT_LINEAR GRADIENT_RADIAL GRADIENT_ANGULAR GRADIENT_DIAMOND IMAGE
|
||||
enum ImageScaleMode STRETCH FIT FILL TILE
|
||||
enum WindingRule NONZERO ODD
|
||||
enum StrokeAlign CENTER INSIDE OUTSIDE
|
||||
enum StrokeCap NONE ROUND SQUARE ARROW_LINES ARROW_EQUILATERAL TRIANGLE_FILLED DIAMOND_FILLED CIRCLE_FILLED
|
||||
enum StrokeJoin MITER BEVEL ROUND
|
||||
enum ConstraintType MIN CENTER MAX STRETCH SCALE
|
||||
enum StackMode NONE HORIZONTAL VERTICAL GRID
|
||||
enum StackAlign MIN CENTER MAX BASELINE SPACE_BETWEEN SPACE_AROUND SPACE_EVENLY
|
||||
enum StackSize FIXED RESIZE_TO_FIT
|
||||
enum StackWrap NO_WRAP WRAP
|
||||
enum StackPositioning AUTO ABSOLUTE
|
||||
enum StackChildAlign AUTO STRETCH
|
||||
enum EffectType INNER_SHADOW DROP_SHADOW FOREGROUND_BLUR BACKGROUND_BLUR NOISE TEXTURE GLASS
|
||||
enum BlurOpType NORMAL PROGRESSIVE
|
||||
enum NoiseType MONOTONE DUOTONE MULTITONE
|
||||
enum TextAlignHorizontal LEFT CENTER RIGHT JUSTIFIED
|
||||
enum TextAlignVertical TOP CENTER BOTTOM
|
||||
enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT
|
||||
enum TextTruncation DISABLED ENDING
|
||||
enum TextCase ORIGINAL UPPER LOWER TITLE SMALL_CAPS
|
||||
enum TextDecoration NONE UNDERLINE STRIKETHROUGH
|
||||
enum NumberUnits RAW PIXELS PERCENT
|
||||
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
|
||||
enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH
|
||||
enum BooleanOperation UNION INTERSECT SUBTRACT XOR
|
||||
enum LayoutGridType MIN CENTER STRETCH MAX
|
||||
enum LayoutGridPattern STRIPES GRID
|
||||
enum Axis X Y
|
||||
enum ExportImageType PNG JPEG SVG PDF WEBP
|
||||
enum ExportConstraintType CONTENT_SCALE CONTENT_WIDTH CONTENT_HEIGHT
|
||||
enum ComponentPropType BOOL TEXT INSTANCE_SWAP VARIANT
|
||||
enum VariableField MISSING VISIBLE TEXT_DATA OVERRIDDEN_SYMBOL_ID
|
||||
enum VariableDataType BOOLEAN FLOAT STRING ALIAS COLOR PROP_REF
|
||||
enum InteractionType ON_CLICK DRAG ON_HOVER ON_PRESS MOUSE_IN MOUSE_OUT MOUSE_DOWN MOUSE_UP AFTER_TIMEOUT ON_KEY_DOWN
|
||||
enum TriggerDevice KEYBOARD UNKNOWN_CONTROLLER
|
||||
enum ConnectionType NONE INTERNAL_NODE URL BACK CLOSE
|
||||
enum NavigationType NAVIGATE OVERLAY SWAP SWAP_STATE SCROLL_TO
|
||||
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE SLIDE_FROM_LEFT SLIDE_FROM_RIGHT SLIDE_FROM_TOP SLIDE_FROM_BOTTOM PUSH_FROM_LEFT PUSH_FROM_RIGHT PUSH_FROM_TOP PUSH_FROM_BOTTOM MOVE_FROM_LEFT MOVE_FROM_RIGHT MOVE_FROM_TOP MOVE_FROM_BOTTOM SLIDE_OUT_TO_LEFT SLIDE_OUT_TO_RIGHT SLIDE_OUT_TO_TOP SLIDE_OUT_TO_BOTTOM MOVE_OUT_TO_LEFT MOVE_OUT_TO_RIGHT MOVE_OUT_TO_TOP MOVE_OUT_TO_BOTTOM
|
||||
enum EasingType IN_CUBIC OUT_CUBIC INOUT_CUBIC LINEAR IN_BACK_CUBIC OUT_BACK_CUBIC INOUT_BACK_CUBIC CUSTOM_CUBIC GENTLE_SPRING
|
||||
enum ScrollDirection NONE HORIZONTAL VERTICAL BOTH
|
||||
enum ScrollBehavior SCROLLS FIXED_WHEN_CHILD_OF_SCROLLING_FRAME STICKY_SCROLLS
|
||||
enum OverlayPositionType CENTER TOP_LEFT TOP_CENTER TOP_RIGHT BOTTOM_LEFT BOTTOM_CENTER BOTTOM_RIGHT MANUAL
|
||||
enum OverlayBackgroundInteraction NONE CLOSE_ON_CLICK_OUTSIDE
|
||||
enum OverlayBackgroundType NONE SOLID_COLOR
|
||||
enum MessageType JOIN_START=0 NODE_CHANGES=1
|
||||
enum NodePhase CREATED=0 REMOVED=1
|
||||
enum NodeType NONE=0 DOCUMENT=1 CANVAS=2 GROUP=3 FRAME=4 BOOLEAN_OPERATION=5 VECTOR=6 STAR=7 LINE=8 ELLIPSE=9 RECTANGLE=10 REGULAR_POLYGON=11 ROUNDED_RECTANGLE=12 TEXT=13 SLICE=14 SYMBOL=15 INSTANCE=16 SECTION=25
|
||||
enum BlendMode PASS_THROUGH=0 NORMAL=1 DARKEN=2 MULTIPLY=3 LINEAR_BURN=4 COLOR_BURN=5 LIGHTEN=6 SCREEN=7 LINEAR_DODGE=8 COLOR_DODGE=9 OVERLAY=10 SOFT_LIGHT=11 HARD_LIGHT=12 DIFFERENCE=13 EXCLUSION=14 HUE=15 SATURATION=16 COLOR=17 LUMINOSITY=18
|
||||
enum PaintType SOLID=0 GRADIENT_LINEAR=1 GRADIENT_RADIAL=2 GRADIENT_ANGULAR=3 GRADIENT_DIAMOND=4 IMAGE=5
|
||||
enum ImageScaleMode STRETCH=0 FIT=1 FILL=2 TILE=3
|
||||
enum WindingRule NONZERO=0 ODD=1
|
||||
enum StrokeAlign CENTER=0 INSIDE=1 OUTSIDE=2
|
||||
enum StrokeCap NONE=0 ROUND=1 SQUARE=2 ARROW_LINES=3 ARROW_EQUILATERAL=4 TRIANGLE_FILLED=6 DIAMOND_FILLED=5 CIRCLE_FILLED=14
|
||||
enum StrokeJoin MITER=0 BEVEL=1 ROUND=2
|
||||
enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4
|
||||
enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3
|
||||
enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3
|
||||
enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5
|
||||
enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5
|
||||
enum StackSize FIXED=0 RESIZE_TO_FIT=1
|
||||
enum StackWrap NO_WRAP=0 WRAP=1
|
||||
enum StackPositioning AUTO=0 ABSOLUTE=1
|
||||
enum EffectType INNER_SHADOW=0 DROP_SHADOW=1 FOREGROUND_BLUR=2 BACKGROUND_BLUR=3 NOISE=7 GRAIN=6 GLASS=8
|
||||
enum BlurOpType NORMAL=0 PROGRESSIVE=1
|
||||
enum NoiseType MONOTONE=1 DUOTONE=2 MULTITONE=0
|
||||
enum TextAlignHorizontal LEFT=0 CENTER=1 RIGHT=2 JUSTIFIED=3
|
||||
enum TextAlignVertical TOP=0 CENTER=1 BOTTOM=2
|
||||
enum TextAutoResize NONE=0 WIDTH_AND_HEIGHT=1 HEIGHT=2
|
||||
enum TextTruncation DISABLED=0 ENDING=1
|
||||
enum TextCase ORIGINAL=0 UPPER=1 LOWER=2 TITLE=3 SMALL_CAPS=4
|
||||
enum TextDecoration NONE=0 UNDERLINE=1 STRIKETHROUGH=2
|
||||
enum NumberUnits RAW=0 PIXELS=1 PERCENT=2
|
||||
enum LineType PLAIN=0 ORDERED_LIST=1 UNORDERED_LIST=2
|
||||
enum SourceDirectionality AUTO=0 LTR=1 RTL=2
|
||||
enum DocumentColorProfile LEGACY=0 SRGB=1 DISPLAY_P3=2
|
||||
enum EmojiImageSet APPLE=0 NOTO=1
|
||||
enum MaskType ALPHA=0 OUTLINE=1 LUMINANCE=2
|
||||
enum VariableResolvedDataType BOOLEAN=0 FLOAT=1 STRING=2 COLOR=4 SYMBOL_ID=6 TEXT_DATA=8
|
||||
enum VectorMirror NONE=0 ANGLE=1 ANGLE_AND_LENGTH=2
|
||||
enum BooleanOperation UNION=0 INTERSECT=1 SUBTRACT=2 XOR=3
|
||||
enum LayoutGridType MIN=0 CENTER=1 STRETCH=2 MAX=3
|
||||
enum LayoutGridPattern STRIPES=0 GRID=1
|
||||
enum Axis X=0 Y=1
|
||||
enum ImageType PNG=0 JPEG=1 SVG=2 PDF=3
|
||||
enum ExportConstraintType CONTENT_SCALE=0 CONTENT_WIDTH=1 CONTENT_HEIGHT=2
|
||||
enum ComponentPropType BOOL=0 TEXT=1 INSTANCE_SWAP=3 VARIANT=4
|
||||
enum VariableField MISSING=0 VISIBLE=10 TEXT_DATA=11 OVERRIDDEN_SYMBOL_ID=37
|
||||
enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 PROP_REF=13
|
||||
enum InteractionType ON_CLICK=0 DRAG=9 ON_HOVER=4 ON_PRESS=7 MOUSE_IN=2 MOUSE_OUT=3 MOUSE_DOWN=5 MOUSE_UP=6 AFTER_TIMEOUT=1 ON_KEY_DOWN=10
|
||||
enum TriggerDevice KEYBOARD=0 UNKNOWN_CONTROLLER=1
|
||||
enum ConnectionType NONE=0 INTERNAL_NODE=1 URL=2 BACK=3 CLOSE=4
|
||||
enum NavigationType NAVIGATE=0 OVERLAY=1 SWAP=2 SWAP_STATE=3 SCROLL_TO=4
|
||||
enum TransitionType INSTANT_TRANSITION=0 DISSOLVE=1 SMART_ANIMATE=24 SLIDE_FROM_LEFT=3 SLIDE_FROM_RIGHT=4 SLIDE_FROM_TOP=5 SLIDE_FROM_BOTTOM=6 PUSH_FROM_LEFT=7 PUSH_FROM_RIGHT=8 PUSH_FROM_TOP=9 PUSH_FROM_BOTTOM=10 MOVE_FROM_LEFT=11 MOVE_FROM_RIGHT=12 MOVE_FROM_TOP=13 MOVE_FROM_BOTTOM=14 SLIDE_OUT_TO_LEFT=15 SLIDE_OUT_TO_RIGHT=16 SLIDE_OUT_TO_TOP=17 SLIDE_OUT_TO_BOTTOM=18 MOVE_OUT_TO_LEFT=19 MOVE_OUT_TO_RIGHT=20 MOVE_OUT_TO_TOP=21 MOVE_OUT_TO_BOTTOM=22
|
||||
enum EasingType IN_CUBIC=0 OUT_CUBIC=1 INOUT_CUBIC=2 LINEAR=3 IN_BACK_CUBIC=4 OUT_BACK_CUBIC=5 INOUT_BACK_CUBIC=6 CUSTOM_CUBIC=7 GENTLE_SPRING=9
|
||||
enum ScrollDirection NONE=0 HORIZONTAL=1 VERTICAL=2 BOTH=3
|
||||
enum ScrollBehavior SCROLLS=0 FIXED_WHEN_CHILD_OF_SCROLLING_FRAME=1 STICKY_SCROLLS=2
|
||||
enum OverlayPositionType CENTER=0 TOP_LEFT=1 TOP_CENTER=2 TOP_RIGHT=3 BOTTOM_LEFT=4 BOTTOM_CENTER=5 BOTTOM_RIGHT=6 MANUAL=7
|
||||
enum OverlayBackgroundInteraction NONE=0 CLOSE_ON_CLICK_OUTSIDE=1
|
||||
enum OverlayBackgroundType NONE=0 SOLID_COLOR=1
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Color float r, float g, float b, float a
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
struct ColorStop Color color, float position
|
||||
message ParentIndex GUID guid, string position
|
||||
message Image byte[] hash, string name
|
||||
message Paint PaintType type, Color color, float opacity, bool visible, BlendMode blendMode, ColorStop[] stops, Matrix transform, Image image, ImageScaleMode imageScaleMode, float rotation, float scale
|
||||
message Effect EffectType type, Color color, Vector offset, float radius, bool visible, BlendMode blendMode, float spread, bool showShadowBehindNode, BlurOpType blurOpType, float startRadius, Vector startOffset, Vector endOffset, NoiseType noiseType, Vector noiseSize, float density, float opacity, Color secondaryColor, bool clipToShape
|
||||
message FontName string family, string style, string postscript
|
||||
message Number float value, NumberUnits units
|
||||
message Hyperlink string url
|
||||
message TextLine LineType lineType
|
||||
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable, TextLine[] lines
|
||||
message Path WindingRule windingRule, uint commandsBlob
|
||||
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
|
||||
message ArcData float startingAngle, float endingAngle, float innerRadius
|
||||
message OptionalVector Vector value
|
||||
message LayoutGrid LayoutGridType type, Axis axis, bool visible, int numSections, float offset, float sectionSize, float gutterSize, Color color, LayoutGridPattern pattern
|
||||
message ExportConstraint ExportConstraintType type, float value
|
||||
message ExportSettings string suffix, ExportImageType imageType, ExportConstraint constraint
|
||||
message GUIDPath GUID[] guids
|
||||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides
|
||||
message ComponentPropValue bool boolValue, TextData textValue, GUID guidValue
|
||||
message ComponentPropDef GUID id, string name, ComponentPropValue initialValue, ComponentPropType type
|
||||
message ComponentPropAssignment GUID defID, ComponentPropValue value
|
||||
message PropRefValue GUID defId
|
||||
message VariableAnyValue PropRefValue propRefValue
|
||||
message VariableData VariableAnyValue value, VariableDataType dataType
|
||||
message VariableMapEntry VariableData variableData, VariableField variableField
|
||||
message VariableDataMap VariableMapEntry[] entries
|
||||
message KeyTrigger int[] keyCodes, TriggerDevice triggerDevice
|
||||
message PrototypeEvent InteractionType interactionType, float transitionTimeout, KeyTrigger keyTrigger
|
||||
message PrototypeAction GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, float[] easingFunction, ConnectionType connectionType, NavigationType navigationType, string connectionURL, bool transitionPreserveScroll
|
||||
message PrototypeInteraction PrototypeEvent event, PrototypeAction[] actions
|
||||
message PrototypeStartingPoint string name, string position
|
||||
message OverlayBackgroundAppearance OverlayBackgroundType backgroundType, Color backgroundColor
|
||||
message GridTrack GUID id, string position
|
||||
message GridTrackMap GridTrack[] entries
|
||||
message NodeChange GUID guid, NodePhase phase, ParentIndex parentIndex, NodeType type, string name, bool visible, bool locked, float opacity, BlendMode blendMode, Vector size, Matrix transform, bool mask, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, StrokeAlign strokeAlign, StrokeJoin strokeJoin, StrokeCap strokeCap, float[] dashPattern, float miterLimit, bool borderStrokeWeightsIndependent, float borderTopWeight, float borderRightWeight, float borderBottomWeight, float borderLeftWeight, Effect[] effects, float cornerRadius, float cornerSmoothing, bool rectangleCornerRadiiIndependent, float rectangleTopLeftCornerRadius, float rectangleTopRightCornerRadius, float rectangleBottomRightCornerRadius, float rectangleBottomLeftCornerRadius, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, bool frameMaskDisabled, bool resizeToFit, bool isStateGroup, bool sectionContentsHidden, Color backgroundColor, StackMode stackMode, float stackSpacing, float stackCounterSpacing, StackWrap stackWrap, float stackHorizontalPadding, float stackVerticalPadding, float stackPaddingRight, float stackPaddingBottom, StackAlign stackPrimaryAlignItems, StackAlign stackCounterAlignItems, StackSize stackPrimarySizing, StackSize stackCounterSizing, bool bordersTakeSpace, bool stackReverseZIndex, float gridColumnGap, float gridRowGap, GridTrackMap gridColumns, GridTrackMap gridRows, float stackChildPrimaryGrow, StackChildAlign stackChildAlignSelf, StackPositioning stackPositioning, OptionalVector minSize, OptionalVector maxSize, GUID gridColumnAnchor, GUID gridRowAnchor, int gridColumnSpan, int gridRowSpan, LayoutGrid[] layoutGrids, ExportSettings[] exportSettings, TextData textData, FontName fontName, float fontSize, Number lineHeight, Number letterSpacing, TextCase textCase, TextDecoration textDecoration, TextAlignHorizontal textAlignHorizontal, TextAlignVertical textAlignVertical, TextAutoResize textAutoResize, TextTruncation textTruncation, int maxLines, float paragraphSpacing, float paragraphIndent, Hyperlink hyperlink, uint styleID, Path[] fillGeometry, VectorData vectorData, VectorMirror handleMirroring, uint count, float starInnerScale, ArcData arcData, BooleanOperation booleanOperation, string symbolDescription, SymbolData symbolData, GUID overriddenSymbolID, ComponentPropDef[] componentPropDefs, ComponentPropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, GUIDPath guidPath, PrototypeInteraction[] prototypeInteractions, PrototypeStartingPoint prototypeStartingPoint, ScrollDirection scrollDirection, ScrollBehavior scrollBehavior, OverlayPositionType overlayPositionType, OverlayBackgroundInteraction overlayBackgroundInteraction, OverlayBackgroundAppearance overlayBackgroundAppearance
|
||||
message Blob byte[] bytes
|
||||
message Message MessageType type, uint sessionID, NodeChange[] nodeChanges, Blob[] blobs
|
||||
struct ParentIndex GUID guid, string position
|
||||
message Image byte[] hash=1, string name=2
|
||||
message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12
|
||||
message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23
|
||||
struct FontName string family, string style, string postscript
|
||||
struct Number float value, NumberUnits units
|
||||
message Hyperlink string url=1
|
||||
message TextLineData LineType lineType=1, int styleId=10, int indentationLevel=2, SourceDirectionality sourceDirectionality=9, int listStartOffset=7, bool isFirstLineOfList=8
|
||||
message TextData string characters=1, uint[] characterStyleIDs=2, NodeChange[] styleOverrideTable=3, TextLineData[] lines=12
|
||||
message Path WindingRule windingRule=1, uint commandsBlob=2, uint styleID=3
|
||||
message VectorData uint vectorNetworkBlob=1, Vector normalizedSize=2, NodeChange[] styleOverrideTable=3
|
||||
message ArcData float startingAngle=1, float endingAngle=2, float innerRadius=3
|
||||
message OptionalVector Vector value=1
|
||||
message LayoutGrid LayoutGridType type=1, Axis axis=2, bool visible=3, int numSections=4, float offset=5, float sectionSize=6, float gutterSize=7, Color color=8, LayoutGridPattern pattern=9
|
||||
struct ExportConstraint ExportConstraintType type, float value
|
||||
message ExportSettings string suffix=1, ImageType imageType=2, ExportConstraint constraint=3
|
||||
message GUIDPath GUID[] guids=1
|
||||
message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2
|
||||
message ComponentPropValue bool boolValue=1, TextData textValue=2, GUID guidValue=3
|
||||
message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, ComponentPropType type=6
|
||||
message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2
|
||||
message PropRefValue GUID defId=1
|
||||
message VariableAnyValue PropRefValue propRefValue=13
|
||||
message VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3
|
||||
message VariableDataMapEntry VariableData variableData=2, VariableField variableField=3
|
||||
message VariableDataMap VariableDataMapEntry[] entries=1
|
||||
message KeyTrigger int[] keyCodes=1, TriggerDevice triggerDevice=2
|
||||
message PrototypeEvent InteractionType interactionType=1, float transitionTimeout=6, KeyTrigger keyTrigger=4
|
||||
message PrototypeAction GUID transitionNodeID=1, TransitionType transitionType=2, float transitionDuration=3, EasingType easingType=4, float[] easingFunction=12, ConnectionType connectionType=7, NavigationType navigationType=10, string connectionURL=8, bool transitionPreserveScroll=11
|
||||
message PrototypeInteraction PrototypeEvent event=2, PrototypeAction[] actions=3
|
||||
message PrototypeStartingPoint string name=1, string position=3
|
||||
message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2
|
||||
message GUIDPositionMapEntry GUID id=1, string position=2
|
||||
message GUIDPositionMap GUIDPositionMapEntry[] entries=1
|
||||
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, SymbolData symbolData=113, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
|
||||
struct Blob byte[] bytes
|
||||
message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6
|
||||
";
|
||||
|
||||
/// Write `doc` as a `.fig` file, with `thumbnail` (a PNG) when given. Returns the bytes and the
|
||||
@@ -158,7 +166,11 @@ pub fn export(doc: &Document, thumbnail: Option<&[u8]>) -> Result<(Vec<u8>, Vec<
|
||||
let root = schema.find("Message").ok_or_else(|| FormatError::Io(".fig export: the schema has no Message".into()))?;
|
||||
let body = encode::message(&schema, root, &message).map_err(fail)?;
|
||||
let canvas = canvas(&encode::schema(&schema), &body)?;
|
||||
let bytes = archive(&doc.name, &canvas, &x.files, thumbnail)?;
|
||||
let background = doc.pages.first().and_then(|p| doc.node(*p)).and_then(|p| match &p.kind {
|
||||
NodeKind::Page { background, .. } => Some(*background),
|
||||
_ => None,
|
||||
});
|
||||
let bytes = archive(&doc.name, background.unwrap_or(Color::WHITE), &canvas, &x.files, thumbnail)?;
|
||||
Ok((bytes, x.warnings))
|
||||
}
|
||||
|
||||
@@ -185,17 +197,29 @@ fn canvas(schema: &[u8], message: &[u8]) -> Result<Vec<u8>, FormatError> {
|
||||
}
|
||||
|
||||
/// The zip around `canvas.fig`.
|
||||
fn archive(name: &str, canvas: &[u8], images: &[(String, Arc<Vec<u8>>)], thumbnail: Option<&[u8]>) -> Result<Vec<u8>, FormatError> {
|
||||
fn archive(
|
||||
name: &str,
|
||||
background: Color,
|
||||
canvas: &[u8],
|
||||
images: &[(String, Arc<Vec<u8>>)],
|
||||
thumbnail: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, FormatError> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
crate::put(&mut z, "canvas.fig", stored, canvas)?;
|
||||
let meta = serde_json::to_vec(&serde_json::json!({"file_name": name})).map_err(crate::io_err)?;
|
||||
crate::put(&mut z, "meta.json", crate::json_options(), &meta)?;
|
||||
if let Some(t) = thumbnail {
|
||||
crate::put(&mut z, "thumbnail.png", stored, t)?;
|
||||
}
|
||||
let mut client = serde_json::json!({"background_color": {"r": background.r, "g": background.g, "b": background.b, "a": background.a}});
|
||||
// A PNG says its size right after its signature and header chunk name.
|
||||
let be = |at: usize| thumbnail.and_then(|t| t.get(at..at + 4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes);
|
||||
if let (Some(w), Some(h)) = (be(16), be(20)) {
|
||||
client["thumbnail_size"] = serde_json::json!({"width": w, "height": h});
|
||||
}
|
||||
let meta = serde_json::to_vec(&serde_json::json!({"client_meta": client, "file_name": name})).map_err(crate::io_err)?;
|
||||
crate::put(&mut z, "meta.json", crate::json_options(), &meta)?;
|
||||
let mut total = canvas.len() as u64;
|
||||
for (hash, bytes) in images {
|
||||
total += bytes.len() as u64;
|
||||
@@ -249,6 +273,18 @@ fn matrix(m: Affine) -> Kv<'static> {
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
o(vec![("m00", fl(a)), ("m01", fl(c)), ("m02", fl(e)), ("m10", fl(b)), ("m11", fl(d)), ("m12", fl(f))])
|
||||
}
|
||||
/// What the document and its pages say besides their names: left out, a reader takes them as
|
||||
/// hidden and fully transparent, and draws nothing below them.
|
||||
fn root_fields() -> Fields {
|
||||
vec![
|
||||
("visible", b(true)),
|
||||
("opacity", fl(1.0)),
|
||||
("transform", matrix(Affine::IDENTITY)),
|
||||
("strokeWeight", fl(0.0)),
|
||||
("strokeAlign", e("CENTER")),
|
||||
("strokeJoin", e("BEVEL")),
|
||||
]
|
||||
}
|
||||
/// A finite coordinate (vector network blobs store raw floats).
|
||||
fn finite(x: f64) -> f64 {
|
||||
if x.is_finite() { x.clamp(-MAX_FLOAT, MAX_FLOAT) } else { 0.0 }
|
||||
@@ -402,7 +438,11 @@ impl<'d> Exporter<'d> {
|
||||
fn message(&mut self) -> Kv<'static> {
|
||||
let doc = self.doc;
|
||||
self.assign();
|
||||
self.nodes.push(o(vec![("guid", guid(0, 0)), ("phase", e("CREATED")), ("type", e("DOCUMENT")), ("name", s(&doc.name))]));
|
||||
let profile = if doc.color_profile == figcraft_doc::color::ColorProfile::DisplayP3 { "DISPLAY_P3" } else { "SRGB" };
|
||||
let mut root: Fields = vec![("guid", guid(0, 0)), ("phase", e("CREATED")), ("type", e("DOCUMENT")), ("name", s(&doc.name))];
|
||||
root.extend(root_fields());
|
||||
root.push(("documentColorProfile", e(profile)));
|
||||
self.nodes.push(o(root));
|
||||
for (i, p) in doc.pages.iter().enumerate() {
|
||||
self.emit(*p, guid(0, 0), i, None, 0);
|
||||
}
|
||||
@@ -493,7 +533,9 @@ impl<'d> Exporter<'d> {
|
||||
("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])),
|
||||
];
|
||||
let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind {
|
||||
f.extend([("type", e("CANVAS")), ("name", s(&n.name)), ("backgroundColor", color(*background))]);
|
||||
f.extend([("type", e("CANVAS")), ("name", s(&n.name))]);
|
||||
f.extend(root_fields());
|
||||
f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]);
|
||||
if !guides.is_empty() {
|
||||
self.warn("ruler guides were left out");
|
||||
}
|
||||
@@ -531,7 +573,7 @@ impl<'d> Exporter<'d> {
|
||||
NodeKind::Page { .. } | NodeKind::Frame | NodeKind::Group | NodeKind::ComponentSet { .. } => "FRAME",
|
||||
NodeKind::Instance { .. } if main.is_none() => "FRAME",
|
||||
NodeKind::Section { .. } => "SECTION",
|
||||
NodeKind::Rectangle => "RECTANGLE",
|
||||
NodeKind::Rectangle => "ROUNDED_RECTANGLE",
|
||||
NodeKind::Ellipse { .. } => "ELLIPSE",
|
||||
NodeKind::Polygon { .. } => "REGULAR_POLYGON",
|
||||
NodeKind::Star { .. } => "STAR",
|
||||
@@ -595,11 +637,13 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. })
|
||||
let boxy = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle | NodeKind::Section { .. });
|
||||
if (boxy || matches!(n.kind, NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. }))
|
||||
&& let Some(p) = figcraft_doc::geometry::shape_path(n)
|
||||
{
|
||||
self.geometry(&p, WindingRule::NonZero, f);
|
||||
self.geometry("fillGeometry", &p, WindingRule::NonZero, f);
|
||||
}
|
||||
self.stroke_geometry(n, boxy, f);
|
||||
main.is_none()
|
||||
}
|
||||
|
||||
@@ -614,10 +658,12 @@ impl<'d> Exporter<'d> {
|
||||
("transform", matrix(n.transform)),
|
||||
]);
|
||||
if n.is_mask {
|
||||
f.push(("mask", b(true)));
|
||||
if n.mask_type != MaskType::Alpha {
|
||||
self.warn("outline and luminance masks were written as alpha masks");
|
||||
}
|
||||
let ty = match n.mask_type {
|
||||
MaskType::Alpha => "ALPHA",
|
||||
MaskType::Vector => "OUTLINE",
|
||||
MaskType::Luminance => "LUMINANCE",
|
||||
};
|
||||
f.extend([("mask", b(true)), ("maskType", e(ty))]);
|
||||
}
|
||||
// Text whose own style sets fills paints with them (the layer's fills only show through runs).
|
||||
let fills = match &n.kind {
|
||||
@@ -635,9 +681,10 @@ impl<'d> Exporter<'d> {
|
||||
if n.kind.has_corner_radius() {
|
||||
let r = n.corners.radii;
|
||||
f.push(("cornerRadius", fl(r[0])));
|
||||
if n.kind.has_independent_corners() && r.iter().any(|x| *x != r[0]) {
|
||||
let differ = r.iter().any(|x| *x != r[0]);
|
||||
if n.kind.has_independent_corners() && (differ || r[0] > 0.0) {
|
||||
f.extend(differ.then(|| ("rectangleCornerRadiiIndependent", b(true))));
|
||||
f.extend([
|
||||
("rectangleCornerRadiiIndependent", b(true)),
|
||||
("rectangleTopLeftCornerRadius", fl(r[0])),
|
||||
("rectangleTopRightCornerRadius", fl(r[1])),
|
||||
("rectangleBottomRightCornerRadius", fl(r[2])),
|
||||
@@ -676,6 +723,9 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
self.child_layout(n, parent, f);
|
||||
if !n.exports.is_empty() {
|
||||
if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::Webp) {
|
||||
self.warn("WebP export settings were written as PNG");
|
||||
}
|
||||
f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect())));
|
||||
}
|
||||
self.prototype(n, f);
|
||||
@@ -888,7 +938,7 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
EffectKind::Texture(t) => {
|
||||
f.extend([
|
||||
("type", e("TEXTURE")),
|
||||
("type", e("GRAIN")),
|
||||
("noiseSize", grain(t.size, t.size_y)),
|
||||
("radius", fl(t.radius)),
|
||||
("clipToShape", b(t.clip_to_shape)),
|
||||
@@ -1033,6 +1083,16 @@ impl<'d> Exporter<'d> {
|
||||
("textAutoResize", e(resize)),
|
||||
("paragraphSpacing", fl(td.paragraph_spacing)),
|
||||
("paragraphIndent", fl(td.paragraph_indent)),
|
||||
("autoRename", b(false)),
|
||||
("fontVariantCommonLigatures", b(true)),
|
||||
("fontVariantContextualLigatures", b(true)),
|
||||
("fontVersion", s("")),
|
||||
("textUserLayoutVersion", u(5)),
|
||||
("textExplicitLayoutVersion", u(1)),
|
||||
("textBidiVersion", u(1)),
|
||||
("textTracking", fl(0.0)),
|
||||
("textDecorationSkipInk", b(true)),
|
||||
("emojiImageSet", e("APPLE")),
|
||||
]);
|
||||
if td.auto_resize == TextAutoResize::Truncate {
|
||||
f.push(("textTruncation", e("ENDING")));
|
||||
@@ -1090,19 +1150,25 @@ impl<'d> Exporter<'d> {
|
||||
data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]);
|
||||
}
|
||||
let lists: Vec<ListStyle> = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect();
|
||||
if lists.iter().any(|l| *l != ListStyle::None) {
|
||||
let line = |l: &ListStyle| {
|
||||
o(vec![(
|
||||
"lineType",
|
||||
e(match l {
|
||||
ListStyle::None => "PLAIN",
|
||||
ListStyle::Numbered => "ORDERED_LIST",
|
||||
ListStyle::Bulleted => "UNORDERED_LIST",
|
||||
}),
|
||||
)])
|
||||
// One entry per paragraph, each with every field a reader expects.
|
||||
let line = |(i, l): (usize, &ListStyle)| {
|
||||
let ty = match l {
|
||||
ListStyle::None => "PLAIN",
|
||||
ListStyle::Numbered => "ORDERED_LIST",
|
||||
ListStyle::Bulleted => "UNORDERED_LIST",
|
||||
};
|
||||
data.push(("lines", arr(lists.iter().map(line).collect())));
|
||||
}
|
||||
let listed = *l != ListStyle::None;
|
||||
let first = listed && (i == 0 || lists.get(i - 1) != Some(l));
|
||||
o(vec![
|
||||
("lineType", e(ty)),
|
||||
("styleId", int(0)),
|
||||
("indentationLevel", int(i64::from(listed))),
|
||||
("sourceDirectionality", e("AUTO")),
|
||||
("listStartOffset", int(0)),
|
||||
("isFirstLineOfList", b(first)),
|
||||
])
|
||||
};
|
||||
data.push(("lines", arr(lists.iter().enumerate().map(line).collect())));
|
||||
f.push(("textData", o(data)));
|
||||
if (0..td.paragraph_count()).any(|p| td.para_list(p).indent > 0) {
|
||||
self.warn("nested list levels were written as the first level");
|
||||
@@ -1216,18 +1282,77 @@ impl<'d> Exporter<'d> {
|
||||
if net.regions.iter().any(|r| r.winding != winding) {
|
||||
self.warn("vectors whose filled areas use different winding rules use the first one's in their outline");
|
||||
}
|
||||
self.geometry(&net.fill_path(), winding, f);
|
||||
self.geometry("fillGeometry", &net.fill_path(), winding, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// `fillGeometry` from a local path.
|
||||
fn geometry(&mut self, p: &BezPath, winding: WindingRule, f: &mut Fields) {
|
||||
/// `fillGeometry` or `strokeGeometry` (`key`) from a local path.
|
||||
fn geometry(&mut self, key: &'static str, p: &BezPath, winding: WindingRule, f: &mut Fields) {
|
||||
if p.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(i) = self.blob(path_blob(p)) {
|
||||
let rule = if winding == WindingRule::EvenOdd { "ODD" } else { "NONZERO" };
|
||||
f.push(("fillGeometry", arr(vec![o(vec![("windingRule", e(rule)), ("commandsBlob", u(i))])])));
|
||||
f.push((key, arr(vec![o(vec![("windingRule", e(rule)), ("commandsBlob", u(i)), ("styleID", u(0))])])));
|
||||
}
|
||||
}
|
||||
|
||||
/// `strokeGeometry`, the outline of a plain stroke: a ring around boxes and whole ellipses, the
|
||||
/// widened path of other centred strokes. Dashed, per-side and other strokes get none (a reader
|
||||
/// builds theirs from the stroke settings).
|
||||
fn stroke_geometry(&mut self, n: &Node, boxy: bool, f: &mut Fields) {
|
||||
let sp = &n.stroke;
|
||||
let (w, h, weight) = (n.width, n.height, sp.weight);
|
||||
let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none();
|
||||
if !plain || !(weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9);
|
||||
if boxy || whole {
|
||||
let (out, inset) = match sp.align {
|
||||
StrokeAlign::Inside => (0.0, weight),
|
||||
StrokeAlign::Center => (weight / 2.0, weight / 2.0),
|
||||
StrokeAlign::Outside => (weight, 0.0),
|
||||
};
|
||||
// The shape grown by `d` (shrunk when negative), or nothing when it shrinks away.
|
||||
let grown = |d: f64| -> Option<BezPath> {
|
||||
let (gw, gh) = (w + 2.0 * d, h + 2.0 * d);
|
||||
if gw <= 0.0 || gh <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut p = if whole {
|
||||
figcraft_doc::geom::ellipse(gw, gh, 0.0, std::f64::consts::TAU, 0.0)
|
||||
} else {
|
||||
let radii = n.corners.radii.map(|r| if r > 0.0 { (r + d).max(0.0) } else { 0.0 });
|
||||
figcraft_doc::geom::rounded_rect(gw, gh, radii, n.corners.smoothing)
|
||||
};
|
||||
p.apply_affine(Affine::translate((-d, -d)));
|
||||
Some(p)
|
||||
};
|
||||
let Some(mut ring) = grown(out) else { return };
|
||||
ring.extend(grown(-inset).iter().flat_map(|p| p.elements().iter().copied()));
|
||||
self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f);
|
||||
} else if sp.align == StrokeAlign::Center {
|
||||
let path = match &n.kind {
|
||||
NodeKind::Vector { network } => Some(network.stroke_path()),
|
||||
NodeKind::Text(_) => None,
|
||||
_ => figcraft_doc::geometry::shape_path(n),
|
||||
};
|
||||
let Some(path) = path.filter(|p| !p.elements().is_empty()) else { return };
|
||||
use figcraft_doc::geom::kurbo::{Cap, Join, Stroke, StrokeOpts, stroke};
|
||||
let cap = match sp.cap_start {
|
||||
StrokeCap::Round => Cap::Round,
|
||||
StrokeCap::Square => Cap::Square,
|
||||
_ => Cap::Butt,
|
||||
};
|
||||
let join = match sp.join {
|
||||
StrokeJoin::Miter => Join::Miter,
|
||||
StrokeJoin::Bevel => Join::Bevel,
|
||||
StrokeJoin::Round => Join::Round,
|
||||
};
|
||||
let limit = if sp.miter_limit.is_finite() { sp.miter_limit.max(1.0) } else { 4.0 };
|
||||
let style = Stroke::new(weight).with_caps(cap).with_join(join).with_miter_limit(limit);
|
||||
self.geometry("strokeGeometry", &stroke(path, &style, &StrokeOpts::default(), 0.05), WindingRule::NonZero, f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1266,17 +1391,21 @@ impl<'d> Exporter<'d> {
|
||||
|
||||
/// Which property controls a layer's `field`.
|
||||
fn prop_ref(&mut self, field: &str, prop: &str) -> Option<Kv<'static>> {
|
||||
let (var_field, ty) = match field {
|
||||
"visible" => ("VISIBLE", "BOOL"),
|
||||
"characters" => ("TEXT_DATA", "TEXT"),
|
||||
"mainComponent" => ("OVERRIDDEN_SYMBOL_ID", "INSTANCE_SWAP"),
|
||||
let (var_field, ty, resolved) = match field {
|
||||
"visible" => ("VISIBLE", "BOOL", "BOOLEAN"),
|
||||
"characters" => ("TEXT_DATA", "TEXT", "TEXT_DATA"),
|
||||
"mainComponent" => ("OVERRIDDEN_SYMBOL_ID", "INSTANCE_SWAP", "SYMBOL_ID"),
|
||||
_ => {
|
||||
self.warn("slot layers were written as plain layers");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let id = *self.defs.get(&(prop.to_string(), ty))?;
|
||||
let data = o(vec![("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])), ("dataType", e("PROP_REF"))]);
|
||||
let data = o(vec![
|
||||
("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])),
|
||||
("dataType", e("PROP_REF")),
|
||||
("resolvedDataType", e(resolved)),
|
||||
]);
|
||||
Some(o(vec![("variableData", data), ("variableField", e(var_field))]))
|
||||
}
|
||||
|
||||
@@ -1590,10 +1719,10 @@ fn key_codes(key: &str) -> Option<(Vec<i64>, bool)> {
|
||||
/// The text style fields of a layer or of an override table entry.
|
||||
fn style_fields(st: &TextStyle, f: &mut Fields) {
|
||||
let line_height = match st.line_height {
|
||||
// 100 % in percent units reads as the font's own line height: a raw multiplier keeps it.
|
||||
// Percent units count from the font's own line height (100 % is "auto"); a share of the
|
||||
// font size is a raw multiplier.
|
||||
LineHeight::Auto => o(vec![("value", fl(100.0)), ("units", e("PERCENT"))]),
|
||||
LineHeight::Percent(p) if (p - 100.0).abs() < 1e-9 => o(vec![("value", fl(1.0)), ("units", e("RAW"))]),
|
||||
LineHeight::Percent(p) => o(vec![("value", fl(p)), ("units", e("PERCENT"))]),
|
||||
LineHeight::Percent(p) => o(vec![("value", fl(p / 100.0)), ("units", e("RAW"))]),
|
||||
LineHeight::Pixels(v) => o(vec![("value", fl(v)), ("units", e("PIXELS"))]),
|
||||
};
|
||||
let letter_spacing = match st.letter_spacing {
|
||||
@@ -1613,7 +1742,7 @@ fn style_fields(st: &TextStyle, f: &mut Fields) {
|
||||
TextDecoration::Strikethrough => "STRIKETHROUGH",
|
||||
};
|
||||
f.extend([
|
||||
("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&st.font_style)), ("postscript", s(""))])),
|
||||
("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&super::style_name(&st.font_style))), ("postscript", s(""))])),
|
||||
("fontSize", fl(st.font_size)),
|
||||
("lineHeight", line_height),
|
||||
("letterSpacing", letter_spacing),
|
||||
@@ -1652,11 +1781,11 @@ fn layout_grid(g: &figcraft_doc::LayoutGrid) -> Kv<'static> {
|
||||
|
||||
fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> {
|
||||
let format = match x.format {
|
||||
figcraft_doc::ExportFormat::Png => "PNG",
|
||||
// The format has no WebP export setting.
|
||||
figcraft_doc::ExportFormat::Png | figcraft_doc::ExportFormat::Webp => "PNG",
|
||||
figcraft_doc::ExportFormat::Jpg => "JPEG",
|
||||
figcraft_doc::ExportFormat::Svg => "SVG",
|
||||
figcraft_doc::ExportFormat::Pdf => "PDF",
|
||||
figcraft_doc::ExportFormat::Webp => "WEBP",
|
||||
};
|
||||
let (ty, value) = match x.size {
|
||||
ExportSize::Scale { value } => ("CONTENT_SCALE", value),
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
//! What other readers of `.fig` files need from the export: the document and its pages say they
|
||||
//! are visible and opaque, every drawn layer carries its outline, text carries its layout
|
||||
//! settings, and the schema written into the file numbers its definitions, fields and enum values
|
||||
//! the way the format does.
|
||||
|
||||
use figcraft_doc::color::{Color, Paint};
|
||||
use figcraft_doc::*;
|
||||
|
||||
use super::kiwi::{Kind, Kv, Schema, Type};
|
||||
use super::*;
|
||||
|
||||
/// One page with a filled frame holding a rectangle, a stroked ellipse, a centred line and text.
|
||||
fn sample() -> Document {
|
||||
let mut doc = Document::new("Interop");
|
||||
let page = doc.first_page().unwrap();
|
||||
let add = |doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str, w: f64, h: f64, edit: &dyn Fn(&mut Node)| {
|
||||
let id = doc.create(parent, kind, name, 10.0, 10.0, w, h).unwrap();
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
n.fills = vec![Paint::solid(Color::new(1.0, 0.0, 0.0, 1.0))];
|
||||
n.strokes.clear();
|
||||
edit(n);
|
||||
id
|
||||
};
|
||||
let frame = add(&mut doc, page, NodeKind::Frame, "Frame", 200.0, 200.0, &|_| {});
|
||||
add(&mut doc, frame, NodeKind::Rectangle, "Rect", 80.0, 60.0, &|n| n.corners.radii = [6.0; 4]);
|
||||
add(&mut doc, frame, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse", 70.0, 70.0, &|n| {
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 4.0;
|
||||
n.stroke.align = StrokeAlign::Inside;
|
||||
});
|
||||
add(&mut doc, frame, NodeKind::Line, "Line", 50.0, 0.0, &|n| {
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 2.0;
|
||||
n.stroke.align = StrokeAlign::Center;
|
||||
});
|
||||
add(&mut doc, frame, NodeKind::Text(TextData::new("One\nTwo")), "Text", 60.0, 40.0, &|_| {});
|
||||
doc
|
||||
}
|
||||
|
||||
/// The node changes of the written file, by type name.
|
||||
fn with_changes(doc: &Document, check: impl Fn(&dyn Fn(&str) -> Kv<'static>, &Kv<'static>)) {
|
||||
let (bytes, _) = export(doc, Some(&super::tests::png())).unwrap();
|
||||
let archive = Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
|
||||
// Decoded values borrow their names from the schema.
|
||||
let schema: &'static Schema = Box::leak(Box::new(schema));
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let of = |ty: &str| {
|
||||
let found = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("type").and_then(Kv::str) == Some(ty));
|
||||
found.unwrap_or_else(|| panic!("no {ty} was written")).clone()
|
||||
};
|
||||
check(&of, &msg);
|
||||
}
|
||||
|
||||
fn num(v: &Kv, key: &str) -> f64 {
|
||||
v.get(key).and_then(Kv::f64).unwrap_or_else(|| panic!("{key} is missing"))
|
||||
}
|
||||
|
||||
fn flag(v: &Kv, key: &str) -> bool {
|
||||
v.get(key).and_then(Kv::bool).unwrap_or_else(|| panic!("{key} is missing"))
|
||||
}
|
||||
|
||||
fn name<'a>(v: &'a Kv, key: &str) -> &'a str {
|
||||
v.get(key).and_then(Kv::str).unwrap_or_else(|| panic!("{key} is missing"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_and_its_pages_are_visible_and_opaque() {
|
||||
with_changes(&sample(), |of, _| {
|
||||
for ty in ["DOCUMENT", "CANVAS"] {
|
||||
let n = of(ty);
|
||||
assert!(flag(&n, "visible"), "{ty}");
|
||||
assert_eq!(num(&n, "opacity"), 1.0, "{ty}");
|
||||
assert_eq!(num(n.get("transform").unwrap(), "m00"), 1.0, "{ty}");
|
||||
assert_eq!(num(n.get("transform").unwrap(), "m11"), 1.0, "{ty}");
|
||||
assert_eq!(num(&n, "strokeWeight"), 0.0, "{ty}");
|
||||
assert_eq!((name(&n, "strokeAlign"), name(&n, "strokeJoin")), ("CENTER", "BEVEL"), "{ty}");
|
||||
}
|
||||
assert_eq!(name(&of("DOCUMENT"), "documentColorProfile"), "SRGB");
|
||||
let page = of("CANVAS");
|
||||
assert!(flag(&page, "backgroundEnabled"));
|
||||
assert_eq!(num(&page, "backgroundOpacity"), 1.0);
|
||||
assert_eq!(num(page.get("backgroundColor").unwrap(), "a"), 1.0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_colour_profile_and_page_opacity_follow_the_document() {
|
||||
let mut doc = sample();
|
||||
doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3;
|
||||
let page = doc.first_page().unwrap();
|
||||
if let Some(NodeKind::Page { background, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
|
||||
*background = Color::new(0.5, 0.5, 0.5, 0.25);
|
||||
}
|
||||
with_changes(&doc, |of, _| {
|
||||
assert_eq!(name(&of("DOCUMENT"), "documentColorProfile"), "DISPLAY_P3");
|
||||
assert_eq!(num(&of("CANVAS"), "backgroundOpacity"), 0.25);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawn_layers_carry_their_outlines() {
|
||||
with_changes(&sample(), |of, msg| {
|
||||
let blobs = msg.get("blobs").unwrap().list();
|
||||
let path = |n: &Kv, key: &str| -> (String, Vec<u8>) {
|
||||
let list = n.get(key).map(Kv::list).unwrap_or_default();
|
||||
let p = list.first().unwrap_or_else(|| panic!("{key} is missing"));
|
||||
assert_eq!(num(p, "styleID"), 0.0);
|
||||
let blob = blobs.get(num(p, "commandsBlob") as usize).and_then(|b| b.get("bytes")).and_then(Kv::bytes).unwrap();
|
||||
(name(p, "windingRule").to_string(), blob.to_vec())
|
||||
};
|
||||
// Sub-paths (move commands) of a geometry blob.
|
||||
let moves = |blob: &[u8]| {
|
||||
let (mut i, mut n) = (0, 0);
|
||||
while let Some(cmd) = blob.get(i) {
|
||||
n += usize::from(*cmd == 1);
|
||||
i += 1 + 8 * [0, 1, 1, 2, 3].get(usize::from(*cmd)).copied().unwrap_or(0);
|
||||
}
|
||||
n
|
||||
};
|
||||
for ty in ["FRAME", "ROUNDED_RECTANGLE", "ELLIPSE"] {
|
||||
let (rule, blob) = path(&of(ty), "fillGeometry");
|
||||
assert_eq!(rule, "NONZERO", "{ty}");
|
||||
assert_eq!(moves(&blob), 1, "{ty}");
|
||||
}
|
||||
// An inside stroke is the ring between the shape and the shape moved in by the weight.
|
||||
let (rule, ring) = path(&of("ELLIPSE"), "strokeGeometry");
|
||||
assert_eq!((rule.as_str(), moves(&ring)), ("ODD", 2));
|
||||
let (rule, line) = path(&of("LINE"), "strokeGeometry");
|
||||
assert_eq!(rule, "NONZERO");
|
||||
assert!(moves(&line) >= 1);
|
||||
// Nothing is stroked: no stroke outline.
|
||||
assert!(of("FRAME").get("strokeGeometry").is_none());
|
||||
assert!(of("LINE").get("fillGeometry").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_without_a_simple_outline_get_none() {
|
||||
let mut doc = sample();
|
||||
let ids: Vec<NodeId> = doc.ids().collect();
|
||||
for id in ids {
|
||||
if let Some(n) = doc.node_mut(id).filter(|n| !matches!(n.kind, NodeKind::Page { .. })) {
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 3.0;
|
||||
n.stroke.dashes = vec![4.0, 2.0];
|
||||
}
|
||||
}
|
||||
with_changes(&doc, |of, _| {
|
||||
for ty in ["FRAME", "ROUNDED_RECTANGLE", "ELLIPSE", "LINE", "TEXT"] {
|
||||
assert!(of(ty).get("strokeGeometry").is_none(), "{ty}");
|
||||
}
|
||||
});
|
||||
// A stroke wider than the shape fills it; sizes that aren't numbers write nothing.
|
||||
let mut doc = sample();
|
||||
let ids: Vec<NodeId> = doc.ids().collect();
|
||||
for (i, id) in ids.into_iter().enumerate() {
|
||||
if let Some(n) = doc.node_mut(id).filter(|n| !matches!(n.kind, NodeKind::Page { .. })) {
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = if i % 2 == 0 { 1e6 } else { f64::NAN };
|
||||
n.stroke.align = [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside][i % 3];
|
||||
if i % 4 == 0 {
|
||||
n.width = f64::INFINITY;
|
||||
}
|
||||
}
|
||||
}
|
||||
export(&doc, None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangles_say_their_corners() {
|
||||
with_changes(&sample(), |of, _| {
|
||||
let r = of("ROUNDED_RECTANGLE");
|
||||
assert_eq!(num(&r, "cornerRadius"), 6.0);
|
||||
for key in
|
||||
["rectangleTopLeftCornerRadius", "rectangleTopRightCornerRadius", "rectangleBottomRightCornerRadius", "rectangleBottomLeftCornerRadius"]
|
||||
{
|
||||
assert_eq!(num(&r, key), 6.0, "{key}");
|
||||
}
|
||||
assert!(r.get("rectangleCornerRadiiIndependent").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_carries_its_layout_settings_and_a_line_per_paragraph() {
|
||||
with_changes(&sample(), |of, _| {
|
||||
let t = of("TEXT");
|
||||
assert!(!flag(&t, "autoRename"));
|
||||
assert!(flag(&t, "fontVariantCommonLigatures") && flag(&t, "fontVariantContextualLigatures") && flag(&t, "textDecorationSkipInk"));
|
||||
assert_eq!(name(&t, "fontVersion"), "");
|
||||
assert_eq!((num(&t, "textUserLayoutVersion"), num(&t, "textExplicitLayoutVersion"), num(&t, "textBidiVersion")), (5.0, 1.0, 1.0));
|
||||
assert_eq!(num(&t, "textTracking"), 0.0);
|
||||
assert_eq!(name(&t, "emojiImageSet"), "APPLE");
|
||||
let lines = t.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap_or_default();
|
||||
assert_eq!(lines.len(), 2);
|
||||
for l in lines {
|
||||
assert_eq!((name(l, "lineType"), name(l, "sourceDirectionality")), ("PLAIN", "AUTO"));
|
||||
assert_eq!((num(l, "styleId"), num(l, "indentationLevel"), num(l, "listStartOffset")), (0.0, 0.0, 0.0));
|
||||
assert!(!flag(l, "isFirstLineOfList"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_styles_are_written_as_words_and_both_spellings_read_back_the_same() {
|
||||
for (ours, written) in [
|
||||
("SemiBold", "Semi Bold"),
|
||||
("Semi Bold", "Semi Bold"),
|
||||
("semi-bold", "Semi Bold"),
|
||||
("ExtraBold", "Extra Bold"),
|
||||
("ExtraLight", "Extra Light"),
|
||||
("UltraLight", "Ultra Light"),
|
||||
("DemiBold", "Demi Bold"),
|
||||
("SemiBoldItalic", "Semi Bold Italic"),
|
||||
("SemiBold Italic", "Semi Bold Italic"),
|
||||
("BoldItalic", "Bold Italic"),
|
||||
("Regular Italic", "Italic"),
|
||||
("Italic", "Italic"),
|
||||
("Regular", "Regular"),
|
||||
("Thin", "Thin"),
|
||||
("Black", "Black"),
|
||||
// Names that aren't the standard weights stay as written.
|
||||
("Condensed SemiBold", "Condensed SemiBold"),
|
||||
("Heavy", "Heavy"),
|
||||
("Oblique", "Oblique"),
|
||||
("", ""),
|
||||
("Ünïcode Ⅻ", "Ünïcode Ⅻ"),
|
||||
] {
|
||||
assert_eq!(style_name(ours), written, "{ours}");
|
||||
assert_eq!(style_name(written), written, "{written} stays");
|
||||
}
|
||||
// The layer's own style and a styled run are both written as words, and read back as them.
|
||||
let mut doc = sample();
|
||||
let text = doc.ids().find(|id| doc.node(*id).is_some_and(|n| n.kind.text().is_some())).unwrap();
|
||||
if let Some(NodeKind::Text(td)) = doc.node_mut(text).map(|n| &mut n.kind) {
|
||||
td.style.font_style = "SemiBold".into();
|
||||
let mut run = td.style.clone();
|
||||
run.font_style = "ExtraBoldItalic".into();
|
||||
td.runs = vec![StyleRun { start: 0, end: 3, style: run }];
|
||||
}
|
||||
with_changes(&doc, |of, _| {
|
||||
let t = of("TEXT");
|
||||
let font = |v: &Kv| v.get("fontName").map(|f| (name(f, "style").to_string(), name(f, "postscript").to_string())).unwrap();
|
||||
assert_eq!(font(&t), ("Semi Bold".to_string(), String::new()));
|
||||
let table = t.get("textData").and_then(|d| d.get("styleOverrideTable")).map(Kv::list).unwrap_or_default();
|
||||
assert_eq!(table.iter().map(font).collect::<Vec<_>>(), [("Extra Bold Italic".to_string(), String::new())]);
|
||||
});
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let td = back.ids().find_map(|id| back.node(id).and_then(|n| n.kind.text().cloned())).unwrap();
|
||||
assert_eq!(td.style.font_style, "Semi Bold");
|
||||
assert_eq!(td.runs.iter().map(|r| r.style.font_style.as_str()).collect::<Vec<_>>(), ["Extra Bold Italic"]);
|
||||
// A file that spells the style compactly reads as the same style.
|
||||
fn respell(v: &mut Kv) {
|
||||
match v {
|
||||
Kv::Str(s) if s == "Semi Bold" => *s = "SemiBold".into(),
|
||||
Kv::Array(a) => a.iter_mut().for_each(respell),
|
||||
Kv::Object(f) => f.iter_mut().for_each(|(_, x)| respell(x)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (compact, _) = import(&rewritten(&doc, &|msg| respell(msg)), "x").unwrap();
|
||||
let td = compact.ids().find_map(|id| compact.node(id).and_then(|n| n.kind.text().cloned())).unwrap();
|
||||
assert_eq!(td.style.font_style, "Semi Bold");
|
||||
}
|
||||
|
||||
/// The exported file with its message changed by `change`, as a bare canvas.
|
||||
fn rewritten(doc: &Document, change: &dyn Fn(&mut Kv)) -> Vec<u8> {
|
||||
let (bytes, _) = export(doc, None).unwrap();
|
||||
let (schema, message) = decode_canvas(&Archive::open(&bytes).unwrap().canvas).unwrap();
|
||||
let mut msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
change(&mut msg);
|
||||
super::tests::canvas(&schema, &msg)
|
||||
}
|
||||
|
||||
type NodeFields<'a, 's> = &'a mut Vec<(&'s str, Kv<'s>)>;
|
||||
|
||||
fn each_node(msg: &mut Kv, f: &dyn Fn(NodeFields)) {
|
||||
if let Kv::Object(top) = msg {
|
||||
for (k, v) in top.iter_mut() {
|
||||
if let (true, Kv::Array(nodes)) = (*k == "nodeChanges", v) {
|
||||
for n in nodes.iter_mut() {
|
||||
if let Kv::Object(fields) = n {
|
||||
f(fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_heights_say_what_they_count_from() {
|
||||
for (ours, value, units) in [
|
||||
(LineHeight::Auto, 100.0, "PERCENT"),
|
||||
(LineHeight::Percent(140.0), 1.4, "RAW"),
|
||||
(LineHeight::Percent(100.0), 1.0, "RAW"),
|
||||
(LineHeight::Pixels(20.0), 20.0, "PIXELS"),
|
||||
] {
|
||||
let mut doc = sample();
|
||||
let text = doc.ids().find(|id| doc.node(*id).is_some_and(|n| n.kind.text().is_some())).unwrap();
|
||||
if let Some(NodeKind::Text(td)) = doc.node_mut(text).map(|n| &mut n.kind) {
|
||||
td.style.line_height = ours;
|
||||
}
|
||||
with_changes(&doc, |of, _| {
|
||||
let t = of("TEXT");
|
||||
let lh = t.get("lineHeight").unwrap();
|
||||
assert!((num(lh, "value") - value).abs() < 1e-6, "{ours:?}");
|
||||
assert_eq!(name(lh, "units"), units, "{ours:?}");
|
||||
});
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let td = back.ids().find_map(|id| back.node(id).and_then(|n| n.kind.text().cloned())).unwrap();
|
||||
match (td.style.line_height, ours) {
|
||||
(LineHeight::Percent(a), LineHeight::Percent(b)) => assert!((a - b).abs() < 1e-3, "{ours:?}"),
|
||||
(a, b) => assert_eq!(a, b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sides_without_a_weight_have_no_border() {
|
||||
let mut doc = sample();
|
||||
let frame = doc.ids().find(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Frame))).unwrap();
|
||||
if let Some(n) = doc.node_mut(frame) {
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.stroke.weight = 1.0;
|
||||
n.stroke.sides = Some([0.0, 0.0, 1.0, 0.0]);
|
||||
}
|
||||
let sides = |bytes: &[u8]| {
|
||||
let (back, _) = import(bytes, "x").unwrap();
|
||||
back.ids().find_map(|id| back.node(id).filter(|n| matches!(n.kind, NodeKind::Frame)).and_then(|n| n.stroke.sides))
|
||||
};
|
||||
assert_eq!(sides(&export(&doc, None).unwrap().0), Some([0.0, 0.0, 1.0, 0.0]));
|
||||
// Other writers leave the sides without a border out.
|
||||
let sparse =
|
||||
rewritten(&doc, &|msg| each_node(msg, &|f| f.retain(|(k, _)| !matches!(*k, "borderTopWeight" | "borderRightWeight" | "borderLeftWeight"))));
|
||||
assert_eq!(sides(&sparse), Some([0.0, 0.0, 1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_types_round_trip() {
|
||||
for ty in [MaskType::Alpha, MaskType::Vector, MaskType::Luminance] {
|
||||
let mut doc = sample();
|
||||
let rect = doc.ids().find(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Rectangle))).unwrap();
|
||||
if let Some(n) = doc.node_mut(rect) {
|
||||
(n.is_mask, n.mask_type) = (true, ty);
|
||||
}
|
||||
with_changes(&doc, |of, _| {
|
||||
let want = ["ALPHA", "OUTLINE", "LUMINANCE"][ty as usize];
|
||||
assert_eq!(name(&of("ROUNDED_RECTANGLE"), "maskType"), want);
|
||||
assert!(of("FRAME").get("maskType").is_none());
|
||||
});
|
||||
let (bytes, warnings) = export(&doc, None).unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("mask")), "{warnings:?}");
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let masks: Vec<MaskType> = back.ids().filter_map(|id| back.node(id)).filter(|n| n.is_mask).map(|n| n.mask_type).collect();
|
||||
assert_eq!(masks, [ty]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_archive_lists_its_members_and_describes_the_file() {
|
||||
let (bytes, _) = export(&sample(), Some(&super::tests::png())).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||||
let names: Vec<String> = (0..z.len()).map(|i| z.by_index(i).unwrap().name().to_string()).collect();
|
||||
assert_eq!(names, ["canvas.fig", "thumbnail.png", "meta.json"]);
|
||||
let meta: serde_json::Value = serde_json::from_slice(&crate::read_member(&mut z, "meta.json", 1 << 20).unwrap()).unwrap();
|
||||
assert_eq!(meta["file_name"], "Interop");
|
||||
assert!(meta["client_meta"]["background_color"]["a"].is_number());
|
||||
assert!(meta["client_meta"]["thumbnail_size"]["width"].as_u64().is_some_and(|w| w > 0));
|
||||
// No thumbnail, or one that isn't a PNG: no size.
|
||||
for thumb in [None, Some(&b"png"[..])] {
|
||||
let (bytes, _) = export(&sample(), thumb).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||||
let meta: serde_json::Value = serde_json::from_slice(&crate::read_member(&mut z, "meta.json", 1 << 20).unwrap()).unwrap();
|
||||
assert!(meta["client_meta"]["thumbnail_size"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_schema_numbers_what_the_format_numbers() {
|
||||
let s = Schema::parse(export::SCHEMA).unwrap();
|
||||
let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap_or_else(|| panic!("no {n}"));
|
||||
for n in ["GUID", "Color", "Vector", "Matrix", "ColorStop", "ParentIndex", "FontName", "Number", "ExportConstraint", "Blob"] {
|
||||
assert_eq!(def(n).kind, Kind::Struct, "{n}");
|
||||
}
|
||||
let id = |d: &str, f: &str| def(d).fields.iter().find(|x| x.name == f).unwrap_or_else(|| panic!("no {d}.{f}")).value;
|
||||
assert_eq!((id("NodeChange", "guid"), id("NodeChange", "size"), id("NodeChange", "transform")), (1, 11, 12));
|
||||
assert_eq!((id("NodeChange", "fillPaints"), id("NodeChange", "fillGeometry"), id("NodeChange", "strokeGeometry")), (38, 51, 52));
|
||||
assert_eq!((id("NodeType", "ROUNDED_RECTANGLE"), id("NodeType", "SECTION"), id("EffectType", "GLASS")), (12, 25, 8));
|
||||
assert_eq!((id("Message", "nodeChanges"), id("Message", "blobs")), (4, 6));
|
||||
// Ids are unique within a definition, and no message field is numbered 0 (the end marker).
|
||||
for d in &s.defs {
|
||||
let mut ids: Vec<u32> = d.fields.iter().map(|f| f.value).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), d.fields.len(), "{}", d.name);
|
||||
assert!(d.kind != Kind::Message || !ids.contains(&0), "{}", d.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_text_takes_numbers_and_refuses_bad_ones() {
|
||||
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=2").unwrap();
|
||||
let values = |i: usize| s.defs[i].fields.iter().map(|f| (f.name.as_str(), f.value)).collect::<Vec<_>>();
|
||||
assert_eq!(values(0), [("A", 3), ("B", 1), ("C", 9)]);
|
||||
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 2)]);
|
||||
for bad in ["enum E A=", "enum E A=x", "enum E A=-1", "message M uint a=1.5", "message M uint a=99999999999", "enum E =", "message M uint ="] {
|
||||
assert!(Schema::parse(bad).is_err() || bad.ends_with('='), "{bad}");
|
||||
}
|
||||
// Junk never panics.
|
||||
let mut seed = 7u64;
|
||||
let alphabet: Vec<char> = "enum struct message uint E M a= =1 ,[] \n9x".chars().collect();
|
||||
for _ in 0..2000 {
|
||||
let text: String = (0..40)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
alphabet[(seed >> 33) as usize % alphabet.len()]
|
||||
})
|
||||
.collect();
|
||||
let _ = Schema::parse(&text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Local only (`FIGCRAFT_FIG_SAMPLES`): every definition, field and enum value the export writes
|
||||
/// has the kind, type and number it has in each sample's own schema.
|
||||
#[test]
|
||||
fn local_samples_agree_with_the_written_schema() {
|
||||
let ours = Schema::parse(export::SCHEMA).unwrap();
|
||||
let type_name = |s: &Schema, f: &super::kiwi::Field| match f.ty {
|
||||
Some(Type::Def(i)) => s.defs[i].name.clone(),
|
||||
t => format!("{t:?}"),
|
||||
};
|
||||
for p in super::tests::samples() {
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
let (theirs, _) = decode_canvas(&Archive::open(&bytes).unwrap().canvas).unwrap();
|
||||
for d in &ours.defs {
|
||||
let t = theirs.defs.iter().find(|x| x.name == d.name).unwrap_or_else(|| panic!("{}: no {}", p.display(), d.name));
|
||||
assert_eq!(t.kind, d.kind, "{}", d.name);
|
||||
if d.kind == Kind::Struct {
|
||||
assert_eq!(t.fields.len(), d.fields.len(), "{}", d.name);
|
||||
}
|
||||
for f in &d.fields {
|
||||
let tf = t.fields.iter().find(|x| x.name == f.name).unwrap_or_else(|| panic!("{}: no {}.{}", p.display(), d.name, f.name));
|
||||
assert_eq!((tf.value, tf.array, type_name(&theirs, tf)), (f.value, f.array, type_name(&ours, f)), "{}.{}", d.name, f.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -555,7 +555,8 @@ const BUILTIN_NAMES: [&str; 8] = ["bool", "byte", "int", "uint", "float", "strin
|
||||
impl Schema {
|
||||
/// A schema from a compact text form, one definition per line: `enum Name A B …` (values from
|
||||
/// 0), `struct Name type field, type field[] …` or `message Name …` (field ids from 1), the
|
||||
/// types being built-in names or other definitions'.
|
||||
/// types being built-in names or other definitions'. `A=3` or `type field=7` gives a value or
|
||||
/// a field its own number.
|
||||
pub fn parse(text: &str) -> Result<Schema, KiwiError> {
|
||||
let lines: Vec<Vec<&str>> = text.lines().map(|l| l.split_whitespace().collect::<Vec<_>>()).filter(|l| !l.is_empty()).collect();
|
||||
let index = |name: &str| lines.iter().position(|l| l.get(1) == Some(&name));
|
||||
@@ -564,7 +565,14 @@ impl Schema {
|
||||
let (Some(kind), Some(name)) = (l.first(), l.get(1)) else { return err("schema text: a definition needs a kind and a name") };
|
||||
let rest = l.get(2..).unwrap_or_default();
|
||||
let (kind, fields) = match *kind {
|
||||
"enum" => (Kind::Enum, rest.iter().zip(0..).map(|(n, i)| Field { name: n.to_string(), ty: None, array: false, value: i }).collect()),
|
||||
"enum" => {
|
||||
let mut fields = Vec::new();
|
||||
for (n, i) in rest.iter().zip(0..) {
|
||||
let (name, value) = numbered(n, i)?;
|
||||
fields.push(Field { name, ty: None, array: false, value });
|
||||
}
|
||||
(Kind::Enum, fields)
|
||||
}
|
||||
"struct" | "message" => {
|
||||
let mut fields = Vec::new();
|
||||
for (f, id) in rest.join(" ").split(',').zip(1..) {
|
||||
@@ -575,7 +583,8 @@ impl Schema {
|
||||
None => index(base).map(Type::Def),
|
||||
}
|
||||
.ok_or_else(|| KiwiError(format!("schema text: unknown type {base}")))?;
|
||||
fields.push(Field { name: n.trim().to_string(), ty: Some(ty), array: t.ends_with("[]"), value: id });
|
||||
let (name, value) = numbered(n.trim(), id)?;
|
||||
fields.push(Field { name, ty: Some(ty), array: t.ends_with("[]"), value });
|
||||
}
|
||||
(if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields)
|
||||
}
|
||||
@@ -586,3 +595,11 @@ impl Schema {
|
||||
Ok(Schema { defs })
|
||||
}
|
||||
}
|
||||
|
||||
/// `name=number` as its parts, or `name` with `default`.
|
||||
fn numbered(token: &str, default: u32) -> Result<(String, u32), KiwiError> {
|
||||
match token.split_once('=') {
|
||||
None => Ok((token.to_string(), default)),
|
||||
Some((name, n)) => Ok((name.to_string(), n.parse().map_err(|_| KiwiError(format!("schema text: bad number in `{token}`")))?)),
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,9 @@ impl<'a> Mapper<'a> {
|
||||
o.insert(to.into(), json!(b));
|
||||
}
|
||||
}
|
||||
if let Some(m) = v.str("maskType") {
|
||||
o.insert("maskType".into(), json!(if m == "OUTLINE" { "VECTOR" } else { m }));
|
||||
}
|
||||
copy_num(o, v, "opacity", "opacity");
|
||||
if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") {
|
||||
o.insert("blendMode".into(), json!(b));
|
||||
@@ -1196,8 +1199,8 @@ fn stroke(o: &mut Obj, v: &View) {
|
||||
o.insert("strokeMiterAngle".into(), json!((2.0 * (1.0 / l).asin()).to_degrees()));
|
||||
}
|
||||
if v.flag("borderStrokeWeightsIndependent") == Some(true) {
|
||||
let w = v.num("strokeWeight").unwrap_or(0.0);
|
||||
let side = |k: &str| v.num(k).unwrap_or(w);
|
||||
// A side that isn't written has no border.
|
||||
let side = |k: &str| v.num(k).unwrap_or(0.0);
|
||||
o.insert(
|
||||
"individualStrokeWeights".into(),
|
||||
json!({"top": side("borderTopWeight"), "right": side("borderRightWeight"), "bottom": side("borderBottomWeight"), "left": side("borderLeftWeight")}),
|
||||
@@ -1432,7 +1435,8 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
if let Some(f) = v.get("fontName") {
|
||||
for (from, to) in [("family", "fontFamily"), ("style", "fontStyle"), ("postscript", "fontPostScriptName")] {
|
||||
if let Some(x) = f.get(from).and_then(Kv::str).filter(|x| !x.is_empty()) {
|
||||
s.insert(to.into(), json!(x));
|
||||
// Compact and spaced style names ("SemiBold", "Semi Bold") are the same style.
|
||||
s.insert(to.into(), if from == "style" { json!(super::style_name(x)) } else { json!(x) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1458,8 +1462,9 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
s.insert("lineHeightUnit".into(), json!("PIXELS"));
|
||||
s.insert("lineHeightPx".into(), json!(val));
|
||||
}
|
||||
// shortcut: 100 % reads as the font's own line height (new text saves "auto" so) and
|
||||
// other percentages as % of the font size; revisit with files that set 100 % explicitly.
|
||||
// shortcut: percent units count from the font's own line height (100 % is "auto"), which
|
||||
// the document can only say for 100 %; other values read as % of the font size. Upgrade
|
||||
// when line heights can be a share of the font's own.
|
||||
Some("PERCENT") if (val - 100.0).abs() < 1e-6 => {
|
||||
s.insert("lineHeightUnit".into(), json!("INTRINSIC_%"));
|
||||
}
|
||||
@@ -1482,6 +1487,7 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
fn effect(e: &Kv) -> Option<Value> {
|
||||
let ty = match e.get("type").and_then(Kv::str)? {
|
||||
"FOREGROUND_BLUR" => "LAYER_BLUR",
|
||||
"GRAIN" => "TEXTURE",
|
||||
t => t,
|
||||
};
|
||||
let mut o = Obj::new();
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
mod export;
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
#[cfg(test)]
|
||||
mod interop_tests;
|
||||
pub mod kiwi;
|
||||
mod map;
|
||||
mod network;
|
||||
@@ -67,6 +69,27 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
/// A font style under the name the format uses: the standard weight names as words ("SemiBold"
|
||||
/// → "Semi Bold", "ExtraBoldItalic" → "Extra Bold Italic", "Regular Italic" → "Italic"). Other
|
||||
/// names stay as they are.
|
||||
fn style_name(style: &str) -> String {
|
||||
const WEIGHTS: [&str; 12] =
|
||||
["Thin", "Extra Light", "Ultra Light", "Light", "Regular", "Medium", "Semi Bold", "Demi Bold", "Bold", "Extra Bold", "Ultra Bold", "Black"];
|
||||
let compact = |s: &str| s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect::<String>();
|
||||
let key = compact(style);
|
||||
let (base, italic) = match key.strip_suffix("italic") {
|
||||
Some(b) => (b, true),
|
||||
None => (key.as_str(), false),
|
||||
};
|
||||
let weight = if base.is_empty() && italic { Some("Regular") } else { WEIGHTS.into_iter().find(|w| compact(w) == base) };
|
||||
match (weight, italic) {
|
||||
(None, _) => style.to_string(),
|
||||
(Some("Regular"), true) => "Italic".into(),
|
||||
(Some(w), true) => format!("{w} Italic"),
|
||||
(Some(w), false) => w.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn corrupt(msg: impl Into<String>) -> FormatError {
|
||||
FormatError::Corrupt(format!(".fig file: {}", msg.into()))
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ fn message() -> Kv<'static> {
|
||||
}
|
||||
|
||||
/// `canvas.fig`: magic, version, deflated schema, zstd message.
|
||||
fn canvas(schema: &Schema, msg: &Kv) -> Vec<u8> {
|
||||
pub(super) fn canvas(schema: &Schema, msg: &Kv) -> Vec<u8> {
|
||||
let root = schema.find("Message").unwrap();
|
||||
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
z.write_all(&encode::schema(schema)).unwrap();
|
||||
|
||||
@@ -21,7 +21,7 @@ pub mod fig;
|
||||
pub mod library;
|
||||
pub mod rest;
|
||||
|
||||
pub use library::{is_valid_library_id, read_library};
|
||||
pub use library::{is_valid_library_id, read_library, read_library_within};
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Read, Seek, Write};
|
||||
@@ -470,7 +470,7 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||||
let hash = name.trim_start_matches("images/").to_string();
|
||||
images.insert(hash, image_data(data));
|
||||
}
|
||||
match library::read(&mut z, &images) {
|
||||
match library::read(&mut z, &images, u64::MAX) {
|
||||
Ok(lib) => doc.published = lib.map(Arc::new),
|
||||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
||||
}
|
||||
|
||||