mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
M7: land Flutter code generation on main's code targets
main split `codegen.rs` into `dev/codegen.rs` (the shell: `CodeLang`, `code_for`, `Ctx`, the shared helpers) and one module per native target (`css`, `html`, `swift`, `compose`, `arkui`), and added Vue and ArkUI from other PRs. Flutter arrives as the tenth target, in its own module: `dev/codegen/flutter.rs`, wired from `codegen.rs` (`CodeLang::Flutter`, `dart` as an alias, rem left to the web targets), with `flutter_definition` / `flutter_type` / `flutter_default` beside main's `arkui_definition`. Every language list in the tree is a union of the two sides now: the `inspect.code` and `inspect.componentCode` specs, `codeConnect` (parse, import line, expression, template, path checks, messages), the playground CSS/HTML assets, the 21 UI catalogues (main added eight after this branch: bn, fi, it, ka, nl, pl, th, vi, each with Flutter in its own conjunction) and the docs. Flutter is an extra code target; nothing main gained is dropped. The reviewer's absolute-child bug (#1382-1403: a child that ignores its auto layout parent fell through to `Place::Flow`) is fixed in `flutter_children`: flow and absolute children are split, the absolute ones go in a `Stack` around the Row/Column at the parent's own box (so their offsets stay the parent's and the padding moves onto the line instead of insetting them), the line fills that Stack (`Positioned.fill`, so `Expanded` still has a bounded width), and only flow children now decide `CrossAxisAlignment.stretch` and `Expanded` — the absolute child no longer joins the line's spacing or loses its offsets. `flutter_lifts_a_child_that_ignores_its_auto_layout_parent_out_of_the_line` covers both kinds of child in one auto-layout frame: a 50×20 flow child and a 30×30 absolute one asking for `fill` on both axes, in a parent with 4 pt padding. It checks the offset, that the absolute child is out of the Row, that no `Expanded`/stretch comes from it, and that the padding insets the line only. ROADMAP and dev-mode-parity keep main's header (2026-10-26) and take this branch's history row in date order (2026-10-16). `cargo test`: engine 1391 pass, ui-egui 814 + 8 + 1 pass; fmt and clippy clean. The one engine failure, `board_tables_tests::a_double_click…`, fails the same way on a pristine `origin/main` here: this sandbox has no `craft-fonts` checkout, so its text doesn't wrap and the row can't grow.
This commit is contained in:
@@ -3,5 +3,13 @@
|
||||
[env]
|
||||
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
|
||||
|
||||
# The Windows UI Automation adapter can be asked about a node that disappeared in the
|
||||
# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
|
||||
# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
|
||||
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
|
||||
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
|
||||
[target.'cfg(all(windows, target_env = "gnu"))']
|
||||
rustflags = ["--cfg=windows_slim_errors"]
|
||||
|
||||
[alias]
|
||||
xtask = "run -q -p xtask --"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/plugins"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
+15
-73
@@ -1,9 +1,7 @@
|
||||
# CI for pull requests and main: format, clippy, tests, dependency layering, asset attribution and
|
||||
# the wasm32 check (the same gates as `cargo xtask ci`). Linux only: macOS is built and tested only
|
||||
# by release.yml (the org's macOS runners are kept for releases; craftrules release/playbook.md),
|
||||
# and Windows/FreeBSD packaging have their own workflows.
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -13,76 +11,20 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
|
||||
# debug info: faster codegen and links, and a smaller target dir.
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every
|
||||
# main state that gets a run is checked to the end.
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 35
|
||||
name: test (ubuntu-latest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
# Only main saves caches; PRs restore main's, so PR caches don't evict main's.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
# Without craft-fonts (the optional CRAFT_FONTS_DIR input, which .cargo/config.toml points at
|
||||
# a sibling checkout that CI doesn't have): everything must build and pass without it.
|
||||
- name: Test
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Dependency layering
|
||||
run: cargo run --locked -q -p xtask -- layers
|
||||
|
||||
- name: Asset attribution
|
||||
run: cargo run --locked -q -p xtask -- assets
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
# The engine, the UI crate and apps/figcraft-web must keep compiling for the browser.
|
||||
- name: Check wasm-safe crates
|
||||
run: cargo run --locked -q -p xtask -- wasm
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Combined Latin presentation and core UI catalog. Required web builds enforce
|
||||
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
require-craft-fonts: true
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
# before it can fail a release. Nothing is uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
@@ -17,6 +15,8 @@ on:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -31,6 +31,5 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
freebsd-release: "14.3"
|
||||
run-package-check: true
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
|
||||
@@ -18,16 +18,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
@@ -24,8 +24,13 @@ jobs:
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-title: FigCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# Combined Latin presentation and core UI catalog. Required web builds enforce
|
||||
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
has-server: true
|
||||
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
|
||||
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -81,6 +81,7 @@ code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
+12
-2
@@ -25,8 +25,18 @@ Release builds embed fonts from the optional craft-fonts build input
|
||||
| Family | Licence | Upstream |
|
||||
|---|---|---|
|
||||
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
|
||||
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
|
||||
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
|
||||
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
|
||||
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
|
||||
|
||||
The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
|
||||
FigCraft filters both families from every build in accordance with its asset policy.
|
||||
|
||||
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
|
||||
|
||||
## egui dependency font icons
|
||||
|
||||
| Asset | Author | Source | Licence | Use |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
|
||||
|
||||
@@ -81,6 +81,7 @@ code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
Generated
+845
-397
File diff suppressed because it is too large
Load Diff
+53
-8
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.11.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" }
|
||||
figcraft-render = { path = "crates/render" }
|
||||
figcraft-svg = { path = "crates/svg" }
|
||||
figcraft-pdf = { path = "crates/pdf" }
|
||||
figcraft-vectordrawable = { path = "crates/vectordrawable" }
|
||||
figcraft-format = { path = "crates/format" }
|
||||
figcraft-tools = { path = "crates/tools" }
|
||||
figcraft-collab = { path = "crates/collab" }
|
||||
@@ -32,6 +33,16 @@ figcraft-mcp = { path = "crates/mcp" }
|
||||
figcraft-collab-net = { path = "crates/collab-net" }
|
||||
figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
|
||||
# Which paths stored in a document may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
|
||||
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
|
||||
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
|
||||
# pull a second egui family version).
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
@@ -39,6 +50,9 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
log = "0.4"
|
||||
same-file = "1.0.6"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
imbl = { version = "7", features = ["serde"] }
|
||||
postcard = { version = "1", default-features = false, features = ["alloc"] }
|
||||
@@ -55,11 +69,15 @@ skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-segmentation = "1.13"
|
||||
# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
|
||||
# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
|
||||
deunicode = "1.6"
|
||||
linesweeper = "0.4"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
crc32fast = "1"
|
||||
@@ -71,9 +89,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
|
||||
wat = "1.261"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
webbrowser = "1"
|
||||
@@ -95,11 +113,15 @@ todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make
|
||||
# the test build faster and the target dir smaller.
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
@@ -110,9 +132,32 @@ codegen-units = 4
|
||||
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
|
||||
[profile.web-release]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate.
|
||||
opt-level = "z"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
debug = false
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
|
||||
# panic while drawing unwinds without aborting.
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
|
||||
@@ -20,3 +20,51 @@ licence texts.
|
||||
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
|
||||
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
|
||||
similar permissive licenses); see each crate's metadata.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
egui icon font subset (dependency asset)
|
||||
----------------------------------------
|
||||
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
|
||||
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
|
||||
uses the same MIT license. Upstream license text follows unchanged.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png" width="128" height="128" alt="FigCraft icon: an engraved chameleon on a mint-green tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">FigCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
|
||||
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
|
||||
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
|
||||
in a native app that opens instantly and works offline. Open source, and yours.
|
||||
</p>
|
||||
@@ -20,6 +24,9 @@
|
||||
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-0EA5C6?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_figcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/figcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -62,7 +69,7 @@
|
||||
<tr>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🎛️ Familiar by design</h3>
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>⚡ Native and fast</h3>
|
||||
@@ -114,6 +121,16 @@
|
||||
<p align="center"><sub><b>Live sessions</b>: three apps on one <code>figcraft-server</code> file, with cursors, selections, cursor chat and spotlight</sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-8-board.png" alt="A board file named Sprint retro on a dot grid: a Sprint 14 retro made from the retrospective template, with Went well, To improve and Next steps sections of sticky notes, vote counts of 3, 2, 1 and 1 on four stickies after a vote has ended, and below it a pull request flow chart from the flow chart template whose shapes are joined by straight and elbow connectors with Yes and No labels; a timer pill in the top right shows 4:30 left with pause, add a minute, stop and Results buttons" width="100%">
|
||||
<p align="center"><sub><b>Boards</b>: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions</sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-9-devmode-measure.png" alt="Dev Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu" width="100%">
|
||||
<p align="center"><sub><b>Dev Mode measurements</b>: redlines placed between layers or along a side, saved in the file and following the layers · <code>examples/ridgeline-app.figcraft</code></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Made in FigCraft
|
||||
@@ -191,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Prototype and hand off</h3>
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -227,8 +244,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **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, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, 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).
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, 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 four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.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.
|
||||
@@ -262,7 +279,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
|
||||
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Privacy and online sharing
|
||||
|
||||
@@ -348,8 +365,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+135
-22
@@ -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-16 · **Change:** minor (Flutter code generation; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -13,19 +13,19 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~155–280 h** one agent | estimated |
|
||||
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
|
||||
| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~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) |
|
||||
| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
|
||||
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
|
||||
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
|
||||
|
||||
@@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–
|
||||
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
|
||||
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
|
||||
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
|
||||
native speakers' reviews of the six language drafts need people.
|
||||
native speakers' reviews of the nine language drafts need people.
|
||||
|
||||
## By dimension
|
||||
|
||||
@@ -63,9 +63,9 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
|
||||
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
||||
## Features
|
||||
@@ -86,38 +86,39 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
|
||||
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
|
||||
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) |
|
||||
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
|
||||
| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) |
|
||||
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
|
||||
|
||||
## Languages
|
||||
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | en | 100% | full |
|
||||
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
|
||||
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 0% | none |
|
||||
| French | fr | 100% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 100% | draft (needs native review) |
|
||||
| German | de | 100% | draft (needs native review) |
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
| French | fr | 95% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
|
||||
| Indonesian | id | 99% | draft (needs native review) |
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 96% | draft (needs native review) |
|
||||
| Korean | ko | 95% | draft (needs native review) |
|
||||
| Vietnamese | vi | 100% | draft (needs native review) |
|
||||
|
||||
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -126,11 +127,45 @@ 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 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).
|
||||
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
|
||||
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
|
||||
- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
|
||||
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
|
||||
- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
|
||||
- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged.
|
||||
- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run
|
||||
unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes
|
||||
into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget
|
||||
can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected
|
||||
(`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets
|
||||
65%.
|
||||
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
|
||||
- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged.
|
||||
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
|
||||
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
|
||||
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
|
||||
- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged.
|
||||
- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged.
|
||||
- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
|
||||
- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
|
||||
- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
|
||||
- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
|
||||
- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed
|
||||
filenames open normally; the global `--no-system-fonts` flag also respects the terminator.
|
||||
Binary regression coverage opens five such native files and checks their JSON info.
|
||||
|
||||
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
|
||||
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
|
||||
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
|
||||
- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged.
|
||||
- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
|
||||
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
|
||||
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
|
||||
@@ -313,11 +348,89 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
|
||||
| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h |
|
||||
| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
|
||||
| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
|
||||
| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
|
||||
| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
|
||||
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
|
||||
| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
|
||||
| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
|
||||
| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
|
||||
| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
|
||||
| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
|
||||
| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
|
||||
| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. |
|
||||
| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged |
|
||||
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
|
||||
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List<Widget>?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged |
|
||||
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
|
||||
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options, and boolean `--flag`s. Unknown options are usage errors.
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -8,15 +9,21 @@ use crate::{CliError, CliResult};
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Args {
|
||||
pub positional: Vec<String>,
|
||||
values: BTreeMap<String, String>,
|
||||
values: BTreeMap<String, Vec<String>>,
|
||||
flags: Vec<String>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
|
||||
let mut a = Args::default();
|
||||
// The first bad option, reported once the rest of the line is checked for `--help`.
|
||||
let mut bad: Option<CliError> = None;
|
||||
let mut it = argv.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
if arg == "--" {
|
||||
a.positional.extend(it.cloned());
|
||||
break;
|
||||
}
|
||||
let name = if let Some(n) = arg.strip_prefix("--") {
|
||||
n
|
||||
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
|
||||
@@ -29,23 +36,36 @@ impl Args {
|
||||
Some((n, v)) => (n, Some(v.to_string())),
|
||||
None => (name, None),
|
||||
};
|
||||
// `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
|
||||
if inline.is_none() && matches!(name, "help" | "h") {
|
||||
return Err(CliError::Help);
|
||||
}
|
||||
if valued.contains(&name) {
|
||||
let v = match inline {
|
||||
Some(v) => v,
|
||||
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
|
||||
};
|
||||
a.values.insert(name.to_string(), v);
|
||||
match inline.or_else(|| it.next().cloned()) {
|
||||
Some(v) => a.values.entry(name.to_string()).or_default().push(v),
|
||||
None => {
|
||||
bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
|
||||
}
|
||||
}
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
return Err(CliError::Usage(format!("unknown option `{arg}`")));
|
||||
bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
match bad {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(a),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
self.values.get(k).cloned()
|
||||
self.values.get(k).and_then(|v| v.last()).cloned()
|
||||
}
|
||||
|
||||
/// Every value of an option that may be given more than once, in order.
|
||||
pub fn values(&self, k: &str) -> &[String] {
|
||||
self.values.get(k).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn flag(&self, k: &str) -> bool {
|
||||
@@ -62,17 +82,26 @@ impl Args {
|
||||
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
|
||||
/// `--bleed-marks`, `--cmyk`.
|
||||
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
|
||||
let bleed = match self.values.get("bleed") {
|
||||
let bleed = match self.value("bleed") {
|
||||
None => 0.0,
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(s)
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
|
||||
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
|
||||
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
|
||||
};
|
||||
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
|
||||
}
|
||||
|
||||
/// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
|
||||
pub fn vd_compat(&self) -> CliResult<figcraft_doc::VdCompat> {
|
||||
match self.value("vd-compat") {
|
||||
None => Ok(figcraft_doc::VdCompat::default()),
|
||||
Some(s) => figcraft_doc::VdCompat::from_key(&s)
|
||||
.ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> CliResult<f64> {
|
||||
match self.values.get("scale") {
|
||||
match self.value("scale") {
|
||||
None => Ok(1.0),
|
||||
Some(s) => s
|
||||
.trim_end_matches('x')
|
||||
@@ -85,7 +114,7 @@ impl Args {
|
||||
|
||||
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
|
||||
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
|
||||
match self.values.get(k) {
|
||||
match self.value(k) {
|
||||
None => Ok(default.to_vec()),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
@@ -110,6 +139,10 @@ mod tests {
|
||||
assert_eq!(a.value("o").as_deref(), Some("out"));
|
||||
assert_eq!(a.scale().unwrap(), 2.0);
|
||||
assert!(a.flag("json"));
|
||||
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
|
||||
assert_eq!(a.values("root"), ["a", "b"]);
|
||||
assert_eq!(a.value("root").as_deref(), Some("b"));
|
||||
assert!(a.values("none").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -125,4 +158,18 @@ mod tests {
|
||||
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
|
||||
assert_eq!(a.positional, ["-5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_asked_for_anywhere_but_as_a_value() {
|
||||
// Also after a bad option: help wins over the usage error (#1161 review).
|
||||
for argv in
|
||||
[&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
|
||||
{
|
||||
assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
|
||||
}
|
||||
let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
|
||||
assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
|
||||
// `--help=x` is no request for help, just an unknown option.
|
||||
assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
|
||||
}
|
||||
}
|
||||
|
||||
+230
-35
@@ -1,10 +1,11 @@
|
||||
//! The file subcommands: info, export, convert, render and run.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
|
||||
use figcraft_engine::{BatchScope, BatchStep, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CliError, CliResult};
|
||||
@@ -19,19 +20,30 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec<String>)> {
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn image_format(e: &str) -> Option<ExportFormat> {
|
||||
match e {
|
||||
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
|
||||
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
|
||||
/// confined run can only write inside a write root (#833).
|
||||
///
|
||||
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
|
||||
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
|
||||
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
|
||||
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
|
||||
/// `Roots::check` has already refused a link that leads out of the root.
|
||||
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
|
||||
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
}
|
||||
if figcraft_engine::file_access::current().is_some() {
|
||||
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -155,6 +167,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
|
||||
})
|
||||
.collect();
|
||||
let missing = figcraft_engine::fonts::missing(&doc);
|
||||
let v = json!({
|
||||
"file": path,
|
||||
"name": doc.name,
|
||||
@@ -164,6 +177,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
"variables": doc.variables.variables.len(),
|
||||
"images": doc.images.len(),
|
||||
"notes": notes,
|
||||
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
|
||||
});
|
||||
if as_json {
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
@@ -187,13 +201,20 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
|
||||
}
|
||||
}
|
||||
for m in &missing {
|
||||
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
|
||||
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
|
||||
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters
|
||||
/// and 200 bytes (file systems allow 255, and a number and the extension still fit).
|
||||
fn sanitize(name: &str) -> String {
|
||||
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
|
||||
let s = s.trim().trim_matches('.').to_string();
|
||||
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
|
||||
let s = figcraft_engine::cmd::file::safe_file_name(name);
|
||||
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() }
|
||||
}
|
||||
|
||||
/// What `export` exports and how.
|
||||
@@ -204,6 +225,8 @@ pub struct Target {
|
||||
pub node: Option<String>,
|
||||
/// PDF only.
|
||||
pub print: PrintSettings,
|
||||
/// Android Vector Drawable (XML) only.
|
||||
pub vd_compat: VdCompat,
|
||||
}
|
||||
|
||||
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
@@ -218,15 +241,20 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
|
||||
let fmt = match t.format.as_deref() {
|
||||
Some(f) => {
|
||||
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
|
||||
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))?
|
||||
}
|
||||
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
|
||||
};
|
||||
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
|
||||
let single = image_format(&ext(out)).is_some();
|
||||
let bytes_of = |ids: &[NodeId]| {
|
||||
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
|
||||
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
|
||||
let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() };
|
||||
let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
// What an Android Vector Drawable changed or left out, like the .fig notes.
|
||||
for w in warnings {
|
||||
eprintln!("note: {w}");
|
||||
}
|
||||
Ok::<_, CliError>(bytes)
|
||||
};
|
||||
if single {
|
||||
let bytes = match target {
|
||||
@@ -238,7 +266,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
|
||||
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let ids = match target {
|
||||
Some(t) => vec![t],
|
||||
None => {
|
||||
@@ -251,14 +279,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
return Err(CliError::Failed("the page is empty: nothing to export".into()));
|
||||
}
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
let mut next_suffix: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for id in ids {
|
||||
let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer"));
|
||||
let mut name = base.clone();
|
||||
let mut k = 2;
|
||||
while !used.insert(name.to_lowercase()) {
|
||||
name = format!("{base} {k}");
|
||||
k += 1;
|
||||
}
|
||||
let base_lower = base.to_lowercase();
|
||||
let next = next_suffix.entry(base_lower).or_insert(1);
|
||||
let name = loop {
|
||||
let k = *next;
|
||||
*next += 1;
|
||||
let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") };
|
||||
if used.insert(candidate.to_lowercase()) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
|
||||
write(&path, &bytes_of(&[id])?)?;
|
||||
println!("{path}");
|
||||
@@ -266,8 +299,29 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
|
||||
if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
|
||||
return Err(CliError::Failed(format!(
|
||||
"{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
|
||||
if let Some((input, images_left_out)) = input {
|
||||
refuse_dropping_images(input, out, images_left_out)?;
|
||||
}
|
||||
if let Some(images_left_out) = active.images_lost_saving_to(out) {
|
||||
refuse_dropping_images(out, out, images_left_out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
|
||||
let doc = load(input)?;
|
||||
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
|
||||
refuse_dropping_images(input, out, loaded.images_past_budget)?;
|
||||
let doc = loaded.doc;
|
||||
let page = doc
|
||||
.current_page
|
||||
.filter(|p| doc.pages.contains(p))
|
||||
@@ -292,7 +346,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
|
||||
}
|
||||
|
||||
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
|
||||
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
|
||||
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
|
||||
Ok(Value::Array(a)) => a,
|
||||
Ok(Value::Object(o)) if o.contains_key("commands") => {
|
||||
@@ -317,36 +371,54 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
Some(p @ Value::Object(_)) => p.clone(),
|
||||
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
|
||||
};
|
||||
Ok((cmd.to_string(), params))
|
||||
let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
|
||||
Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
|
||||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut s = match input {
|
||||
let (mut s, input_images_left_out) = match input {
|
||||
Some(path) => {
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
|
||||
s
|
||||
let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
|
||||
(s, left_out)
|
||||
}
|
||||
None => Session::new(),
|
||||
None => (Session::new(), 0),
|
||||
};
|
||||
for (i, (cmd, params)) in steps.iter().enumerate() {
|
||||
let r = s.execute(cmd, params);
|
||||
s.ui_requests.clear();
|
||||
match r {
|
||||
Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})),
|
||||
Err(e) => {
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()}));
|
||||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
|
||||
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
|
||||
// steps before it, and a step that fails takes the whole script back instead of leaving the
|
||||
// earlier steps applied. The result lines are held back until the script is through, so none of
|
||||
// them reports work that was rolled back.
|
||||
let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut failed_at = 0;
|
||||
let outcome = s.grouped(&format!("run {script}"), |s| {
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
failed_at = i;
|
||||
s.batch_step(&mut scope, &step.command, &step.params)?;
|
||||
s.ui_requests.clear();
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = outcome {
|
||||
let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
|
||||
let i = failed_at;
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
|
||||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||||
eprintln!("the script's document changes were rolled back");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
|
||||
println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
|
||||
}
|
||||
if let Some(out) = out {
|
||||
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
|
||||
if ext(out) == "figcraft" {
|
||||
// Through the engine, so the version history is kept.
|
||||
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
|
||||
@@ -378,6 +450,14 @@ mod tests {
|
||||
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
|
||||
assert_eq!(sanitize("../.."), "_");
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
// The same characters the app's export keeps (#1154): only what file systems refuse goes.
|
||||
assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
|
||||
assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
|
||||
assert_eq!(sanitize("a\\b:c*d?e\"f<g>h|i\tj"), "a_b_c_d_e_f_g_h_i_j");
|
||||
assert_eq!(sanitize(" .hidden. "), "hidden");
|
||||
assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
|
||||
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
|
||||
assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -422,4 +502,119 @@ mod tests {
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
|
||||
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
|
||||
/// as "no parent" and the layer landed on the page — with every step reporting success.
|
||||
#[test]
|
||||
fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
|
||||
let dir = temp_dir("run-step");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let page = s.page().unwrap();
|
||||
let top = doc.node(page).unwrap().children.clone();
|
||||
assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
|
||||
assert_eq!(doc.node(top[0]).unwrap().name, "Card");
|
||||
let inner = doc.node(top[0]).unwrap().children.clone();
|
||||
assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
|
||||
assert_eq!(doc.node(inner[0]).unwrap().name, "child");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A script names its steps with `as` and reads paths into their results, like `command.batch`.
|
||||
#[test]
|
||||
fn a_script_refers_to_named_steps_and_paths() {
|
||||
let dir = temp_dir("run-ref");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let top = doc.node(s.page().unwrap()).unwrap().children.clone();
|
||||
let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
|
||||
assert_eq!(names, ["Card", "Copy"]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Two steps with one name fail before anything runs.
|
||||
#[test]
|
||||
fn a_script_with_a_name_twice_runs_nothing() {
|
||||
let dir = temp_dir("run-dup-name");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
|
||||
let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
|
||||
assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
|
||||
#[test]
|
||||
fn a_failed_step_leaves_no_output() {
|
||||
let dir = temp_dir("run-rollback");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
// The failure is the script's, not the call's: the CLI reports it and exits non-zero.
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
assert!(!out.exists(), "a script that failed writes nothing");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
|
||||
let dir = temp_dir("script-images-left-out");
|
||||
let input = dir.join("big.figcraft");
|
||||
std::fs::write(&input, b"kept").unwrap();
|
||||
let link = dir.join("link.figcraft");
|
||||
std::fs::hard_link(&input, &link).unwrap();
|
||||
let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
|
||||
let duplicate = Session::new();
|
||||
let active = duplicate.ds().unwrap();
|
||||
|
||||
for out in [input, link] {
|
||||
let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
|
||||
assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
|
||||
}
|
||||
assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
|
||||
assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,37 +17,68 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
|
||||
|
||||
USAGE:
|
||||
figcraft-cli info <file> [--json]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version
|
||||
figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
|
||||
|
||||
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
|
||||
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
|
||||
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
|
||||
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
|
||||
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
||||
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
|
||||
and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
|
||||
subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
|
||||
confined by the app it attaches to, not by flags passed to the CLI: start that app with
|
||||
`figcraft --control PORT` and the same roots (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
|
||||
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
|
||||
|
||||
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
|
||||
}
|
||||
|
||||
/// Runs `f` confined to the roots `a` carries.
|
||||
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(a)?.map(std::sync::Arc::new);
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), f)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
Ok(code) => code,
|
||||
Err(CliError::Help) => {
|
||||
println!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(CliError::Usage(msg)) => {
|
||||
eprintln!("error: {msg}\n\n{USAGE}");
|
||||
ExitCode::from(2)
|
||||
@@ -61,6 +92,8 @@ fn main() -> ExitCode {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CliError {
|
||||
/// `--help` or `-h` after a subcommand: usage on stdout, exit code 0.
|
||||
Help,
|
||||
/// Bad arguments: usage is printed, exit code 2.
|
||||
Usage(String),
|
||||
/// The operation failed: exit code 1.
|
||||
@@ -75,15 +108,27 @@ impl From<String> for CliError {
|
||||
|
||||
pub type CliResult<T> = Result<T, CliError>;
|
||||
|
||||
fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
|
||||
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
|
||||
if system_fonts {
|
||||
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
|
||||
let flags = [flags, &["no-system-fonts"]].concat();
|
||||
let args = Args::parse(argv, valued, &flags)?;
|
||||
if !args.flag("no-system-fonts") {
|
||||
// Text lays out with the installed fonts, like the desktop app, so headless renders and
|
||||
// auto layout sizes match what users see.
|
||||
figcraft_text::scan_system_fonts_on_demand();
|
||||
} else {
|
||||
// Nothing scans them then, not even a `.sketch` import matching its fonts.
|
||||
figcraft_text::opt_out_of_system_fonts();
|
||||
}
|
||||
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else {
|
||||
return Err(CliError::Usage("missing subcommand".into()));
|
||||
};
|
||||
let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?;
|
||||
// Keep leading global flags with the subcommand's options, where values and `--` are parsed.
|
||||
let rest: Vec<String> = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect();
|
||||
match cmd.as_str() {
|
||||
"help" | "--help" | "-h" => {
|
||||
println!("{USAGE}");
|
||||
@@ -94,38 +139,47 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"info" => {
|
||||
let a = Args::parse(rest, &[], &["json"])?;
|
||||
let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?;
|
||||
let file = a.one_positional("info <file>")?;
|
||||
files::info(&file, a.flag("json"))
|
||||
confined(&a, || files::info(&file, a.flag("json")))
|
||||
}
|
||||
"export" => {
|
||||
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let file = a.one_positional("export <file> -o <out>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
|
||||
let target =
|
||||
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
|
||||
files::export(&file, &out, &target)
|
||||
let target = files::Target {
|
||||
format: a.value("format"),
|
||||
scale: a.scale()?,
|
||||
page: a.value("page"),
|
||||
node: a.value("node"),
|
||||
print: a.print()?,
|
||||
vd_compat: a.vd_compat()?,
|
||||
};
|
||||
confined(&a, || files::export(&file, &out, &target))
|
||||
}
|
||||
"convert" => {
|
||||
let a = Args::parse(rest, &[], &[])?;
|
||||
let a = parse_args(&rest, &ROOT_FLAGS, &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[i, o] => files::convert(i, o),
|
||||
[i, o] => confined(&a, || files::convert(i, o)),
|
||||
_ => Err(CliError::Usage("convert <in> <out>".into())),
|
||||
}
|
||||
}
|
||||
"render" => {
|
||||
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
|
||||
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
let file = a.one_positional("render <file> -o <out.png>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
|
||||
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
|
||||
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
|
||||
}
|
||||
"run" => {
|
||||
let a = Args::parse(rest, &["in", "out"], &[])?;
|
||||
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
let script = a.one_positional("run <script.json|.jsonl>")?;
|
||||
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
|
||||
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
|
||||
}
|
||||
"commands" => {
|
||||
let a = Args::parse(rest, &["filter"], &["json"])?;
|
||||
let a = parse_args(&rest, &["filter"], &["json"])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("commands takes no positional arguments".into()));
|
||||
}
|
||||
@@ -133,14 +187,23 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let a = Args::parse(rest, &["attach"], &[])?;
|
||||
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = parse_args(&rest, &valued, &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
mcp(a.value("attach").as_deref())
|
||||
let attach = a.value("attach");
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
mcp(attach.as_deref(), roots_of(&a)?)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
let a = parse_args(&rest, &["server"], &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[verb, folder] if verb == "publish" => {
|
||||
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
|
||||
@@ -150,10 +213,10 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
}
|
||||
}
|
||||
"perf" => {
|
||||
let a = Args::parse(rest, &["layers", "instances"], &[])?;
|
||||
let a = parse_args(&rest, &["layers", "instances"], &[])?;
|
||||
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
|
||||
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
|
||||
perf::run(&sizes, &instances, system_fonts)
|
||||
perf::run(&sizes, &instances, !a.flag("no-system-fonts"))
|
||||
}
|
||||
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
|
||||
}
|
||||
@@ -205,7 +268,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
|
||||
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
|
||||
Some(p) => {
|
||||
let addr = if p.contains(':') {
|
||||
@@ -220,7 +283,10 @@ fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
None => Box::new(figcraft_mcp::Headless::new()),
|
||||
};
|
||||
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
|
||||
let mut server = figcraft_mcp::Server::new(backend);
|
||||
if let Some(r) = &roots {
|
||||
eprintln!("file access confined to {}", r.to_json());
|
||||
}
|
||||
let mut server = figcraft_mcp::Server::new(backend).with_roots(roots);
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
//! The binary end to end.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_exit_2_without_panicking() {
|
||||
for args in [
|
||||
&[][..],
|
||||
&["bogus"],
|
||||
&["export"],
|
||||
&["export", "x.figcraft"],
|
||||
&["info", "--nope"],
|
||||
&["render", "a", "-o"],
|
||||
&["perf", "--shapes", "lots"],
|
||||
&["mcp", "--attach", "notaport"],
|
||||
&["convert", "a"],
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("USAGE"), "{args:?}");
|
||||
assert!(!err.contains("panicked"));
|
||||
}
|
||||
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_export_info_convert() {
|
||||
let d = tmp("run");
|
||||
let script = d.join("s.jsonl");
|
||||
std::fs::write(&script, "{\"command\":\"frame.create\",\"params\":{\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"name\":\"A\",\"props\":{\"fill\":\"#ff0000\"}}}\n{\"command\":\"frame.create\",\"params\":{\"x\":200,\"y\":0,\"width\":30,\"height\":30,\"name\":\"B\"}}\n").unwrap();
|
||||
let file = d.join("doc.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines.iter().all(|l| l["ok"] == true));
|
||||
|
||||
let info = bin().args(["info", "--json"]).arg(&file).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
|
||||
assert_eq!(v["pages"][0]["frames"].as_array().unwrap().len(), 2);
|
||||
|
||||
let dir = d.join("png");
|
||||
assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--scale", "2"]).status().unwrap().success());
|
||||
let a = std::fs::read(dir.join("A.png")).unwrap();
|
||||
assert_eq!(&a[16..24], &[0, 0, 0, 200, 0, 0, 0, 100]);
|
||||
assert!(dir.join("B.png").exists());
|
||||
|
||||
let svg = d.join("b.svg");
|
||||
assert!(bin().arg("export").arg(&file).arg("-o").arg(&svg).args(["--node", "B"]).status().unwrap().success());
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
|
||||
let json = d.join("doc.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let png = d.join("doc.png");
|
||||
assert!(bin().arg("convert").arg(&json).arg(&png).status().unwrap().success());
|
||||
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
|
||||
|
||||
// A failing step: nonzero exit, error line printed.
|
||||
let bad = d.join("bad.json");
|
||||
std::fs::write(&bad, r#"[{"command":"select.all"},{"command":"nope"}]"#).unwrap();
|
||||
let out = bin().arg("run").arg(&bad).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("\"ok\":false"));
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_takes_the_documented_gradient_shorthand() {
|
||||
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
|
||||
let d = tmp("gradient");
|
||||
let script = d.join("s.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
|
||||
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
|
||||
)
|
||||
.unwrap();
|
||||
let file = d.join("g.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
let json = d.join("g.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let doc = std::fs::read_to_string(&json).unwrap();
|
||||
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio() {
|
||||
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap();
|
||||
writeln!(stdin, "garbage").unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"create_node","arguments":{{"type":"ellipse"}}}}}}"#)
|
||||
.unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0]["result"]["protocolVersion"], "2025-06-18");
|
||||
assert_eq!(lines[1]["error"]["code"], -32700);
|
||||
assert_eq!(lines[2]["result"]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_json_lists_everything() {
|
||||
let out = bin().args(["commands", "--json"]).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(v.as_array().unwrap().len() > 200);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
|
||||
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The result of one `figcraft-cli run` step (script file `name`); `opt_out` sets the environment
|
||||
/// variable.
|
||||
fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let script = d.join(format!("{name}.json"));
|
||||
std::fs::write(&script, json!([step]).to_string()).unwrap();
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
cmd.arg("run").arg(&script).args(args);
|
||||
if opt_out {
|
||||
cmd.env("FIGCRAFT_NO_SYSTEM_FONTS", "1");
|
||||
} else {
|
||||
cmd.env_remove("FIGCRAFT_NO_SYSTEM_FONTS");
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let line: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
line["result"].clone()
|
||||
}
|
||||
|
||||
fn families(args: &[&str], opt_out: bool) -> Vec<String> {
|
||||
serde_json::from_value(run_one("families", json!({"command": "text.fonts"}), args, opt_out)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_runs_resolve_installed_families_unless_opted_out() {
|
||||
let built_in = families(&["--no-system-fonts"], false);
|
||||
assert_eq!(families(&[], true), built_in, "FIGCRAFT_NO_SYSTEM_FONTS=1 opts out like the flag");
|
||||
// Any family this machine adds (scanned here, in the test); none (CI): nothing to check.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let Some(installed) = figcraft_text::family_names().into_iter().find(|f| !built_in.contains(f)) else {
|
||||
eprintln!("skipped: no installed fonts beyond the built-in ones");
|
||||
return;
|
||||
};
|
||||
assert!(families(&[], false).contains(&installed), "headless runs list {installed}");
|
||||
let face = json!({"command": "text.fonts", "params": {"family": installed, "axes": true}});
|
||||
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
|
||||
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//! The binary end to end.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_terminator_allows_dash_prefixed_filenames() {
|
||||
let dir = tmp("option-terminator");
|
||||
let doc = figcraft_doc::Document::new("Dash-prefixed file");
|
||||
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
|
||||
for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] {
|
||||
std::fs::write(dir.join(name), &bytes).unwrap();
|
||||
let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap();
|
||||
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(info["file"], name);
|
||||
assert_eq!(info["name"], "Dash-prefixed file");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_font_flag_respects_option_values() {
|
||||
for filter in ["--", "--no-system-fonts"] {
|
||||
let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap();
|
||||
assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap().is_array());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_font_flag_can_precede_the_subcommand() {
|
||||
let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(!serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_arguments_exit_2_without_panicking() {
|
||||
for args in [
|
||||
&[][..],
|
||||
&["bogus"],
|
||||
&["export"],
|
||||
&["export", "x.figcraft"],
|
||||
&["info", "--nope"],
|
||||
&["render", "a", "-o"],
|
||||
&["perf", "--shapes", "lots"],
|
||||
&["mcp", "--attach", "notaport"],
|
||||
// An attached app confines itself: roots go to `figcraft --control`.
|
||||
&["mcp", "--attach", "7979", "--automation-read-root", "."],
|
||||
&["mcp", "--automation-write-root"],
|
||||
&["convert", "a"],
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("USAGE"), "{args:?}");
|
||||
assert!(!err.contains("panicked"));
|
||||
}
|
||||
let out = bin().args(["info", "/no/such/file.figcraft"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
}
|
||||
|
||||
/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first
|
||||
/// (#1155), whatever else is on the line; nothing runs.
|
||||
#[test]
|
||||
fn help_after_a_subcommand_prints_the_usage() {
|
||||
for args in [
|
||||
&["--help"][..],
|
||||
&["-h"],
|
||||
&["help"],
|
||||
&["mcp", "--help"],
|
||||
&["mcp", "-h"],
|
||||
&["info", "--help"],
|
||||
&["export", "--help"],
|
||||
&["export", "x.figcraft", "-o", "out", "--help"],
|
||||
&["convert", "-h"],
|
||||
&["render", "--help"],
|
||||
&["run", "--help"],
|
||||
&["commands", "--help"],
|
||||
&["plugin", "publish", "--help"],
|
||||
&["perf", "--help"],
|
||||
&["mcp", "--attach", "notaport", "--help"],
|
||||
&["mcp", "--bogus", "--help"],
|
||||
] {
|
||||
let out = bin().args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}");
|
||||
assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
// A value that happens to be `-h` is still a value, not a request for help.
|
||||
let out = bin().args(["commands", "--filter", "--help"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_export_info_convert() {
|
||||
let d = tmp("run");
|
||||
let script = d.join("s.jsonl");
|
||||
std::fs::write(&script, "{\"command\":\"frame.create\",\"params\":{\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"name\":\"A\",\"props\":{\"fill\":\"#ff0000\"}}}\n{\"command\":\"frame.create\",\"params\":{\"x\":200,\"y\":0,\"width\":30,\"height\":30,\"name\":\"B\"}}\n").unwrap();
|
||||
let file = d.join("doc.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines.iter().all(|l| l["ok"] == true));
|
||||
|
||||
let info = bin().args(["info", "--json"]).arg(&file).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
|
||||
assert_eq!(v["pages"][0]["frames"].as_array().unwrap().len(), 2);
|
||||
|
||||
let dir = d.join("png");
|
||||
assert!(bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--scale", "2"]).status().unwrap().success());
|
||||
let a = std::fs::read(dir.join("A.png")).unwrap();
|
||||
assert_eq!(&a[16..24], &[0, 0, 0, 200, 0, 0, 0, 100]);
|
||||
assert!(dir.join("B.png").exists());
|
||||
|
||||
let svg = d.join("b.svg");
|
||||
assert!(bin().arg("export").arg(&file).arg("-o").arg(&svg).args(["--node", "B"]).status().unwrap().success());
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
|
||||
let json = d.join("doc.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let png = d.join("doc.png");
|
||||
assert!(bin().arg("convert").arg(&json).arg(&png).status().unwrap().success());
|
||||
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
|
||||
|
||||
// A failing step: nonzero exit, error line printed.
|
||||
let bad = d.join("bad.json");
|
||||
std::fs::write(&bad, r#"[{"command":"select.all"},{"command":"nope"}]"#).unwrap();
|
||||
let out = bin().arg("run").arg(&bad).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("\"ok\":false"));
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`,
|
||||
/// `%`, `#` and commas stay, what file systems refuse becomes `_`.
|
||||
#[test]
|
||||
fn export_file_names_keep_what_the_app_keeps() {
|
||||
let d = tmp("names");
|
||||
let script = d.join("s.json");
|
||||
let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"];
|
||||
let steps: Vec<serde_json::Value> = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}}))
|
||||
.collect();
|
||||
std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap();
|
||||
let file = d.join("doc.figcraft");
|
||||
assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success());
|
||||
let dir = d.join("out");
|
||||
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] {
|
||||
assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::<Vec<_>>());
|
||||
}
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_takes_the_documented_gradient_shorthand() {
|
||||
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
|
||||
let d = tmp("gradient");
|
||||
let script = d.join("s.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
|
||||
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
|
||||
)
|
||||
.unwrap();
|
||||
let file = d.join("g.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
let json = d.join("g.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let doc = std::fs::read_to_string(&json).unwrap();
|
||||
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio() {
|
||||
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#).unwrap();
|
||||
writeln!(stdin, "garbage").unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"create_node","arguments":{{"type":"ellipse"}}}}}}"#)
|
||||
.unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0]["result"]["protocolVersion"], "2025-06-18");
|
||||
assert_eq!(lines[1]["error"]["code"], -32700);
|
||||
assert_eq!(lines[2]["result"]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio_confined_to_automation_roots() {
|
||||
let d = tmp("roots");
|
||||
for sub in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
// A root that isn't a folder stops the server before it serves.
|
||||
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
|
||||
let mut child = bin()
|
||||
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
let call = |id: u32, tool: &str, args: serde_json::Value| {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
|
||||
};
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
|
||||
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 6);
|
||||
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
|
||||
let error = |i: usize| lines[i]["result"]["isError"] == true;
|
||||
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
|
||||
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
|
||||
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_json_lists_everything() {
|
||||
let out = bin().args(["commands", "--json"]).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(v.as_array().unwrap().len() > 200);
|
||||
}
|
||||
|
||||
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
|
||||
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
|
||||
/// the same commands behave as they always did.
|
||||
#[test]
|
||||
fn file_subcommands_are_confined_to_automation_roots() {
|
||||
let d = tmp("file-roots");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["in", "out"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
|
||||
// A document to work on, made unconfined.
|
||||
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", out_of(&made));
|
||||
|
||||
// Inside the roots: renders as usual.
|
||||
let ok = bin()
|
||||
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(ok.status.success(), "{}", out_of(&ok));
|
||||
assert!(d.join("out/ok.png").is_file());
|
||||
|
||||
// A write outside the write root is refused, and nothing is written.
|
||||
let escaped = d.join("escaped.png");
|
||||
let refused = bin()
|
||||
.args([
|
||||
"render",
|
||||
&p("in/doc.figcraft"),
|
||||
"-o",
|
||||
&escaped.to_string_lossy(),
|
||||
"--automation-read-root",
|
||||
&p("in"),
|
||||
"--automation-write-root",
|
||||
&p("out"),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!refused.status.success());
|
||||
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
|
||||
assert!(!escaped.exists(), "the refused render wrote a file anyway");
|
||||
|
||||
// No write root at all: writing is refused outright, reading still works.
|
||||
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
|
||||
assert!(!no_write.status.success());
|
||||
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
|
||||
|
||||
// A read outside every root is refused: `info` on the document with only `out` readable.
|
||||
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
|
||||
assert!(!read_refused.status.success());
|
||||
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
|
||||
|
||||
// `convert` and `run` take them as well, and a root that is not a folder is refused.
|
||||
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert!(!missing.status.success());
|
||||
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
|
||||
|
||||
// Unconfined, the same render still writes wherever it is told.
|
||||
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
|
||||
assert!(plain.status.success(), "{}", out_of(&plain));
|
||||
assert!(escaped.is_file());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
|
||||
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
|
||||
/// replaced the link with a regular file, because that write stages the bytes and renames them
|
||||
/// into place.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unconfined_write_follows_a_symbolic_link() {
|
||||
let d = tmp("symlink-out");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("real")).unwrap();
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let target = d.join("real/target.png");
|
||||
std::fs::write(&target, b"placeholder").unwrap();
|
||||
let link = d.join("link.png");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
|
||||
let written = std::fs::read(&target).unwrap();
|
||||
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Depth refusal through the actual CLI, including its exit status and destination writes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
|
||||
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The result of one `figcraft-cli run` step (script file `name`); `opt_out` sets the environment
|
||||
/// variable.
|
||||
fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let script = d.join(format!("{name}.json"));
|
||||
std::fs::write(&script, json!([step]).to_string()).unwrap();
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
cmd.arg("run").arg(&script).args(args);
|
||||
if opt_out {
|
||||
cmd.env("FIGCRAFT_NO_SYSTEM_FONTS", "1");
|
||||
} else {
|
||||
cmd.env_remove("FIGCRAFT_NO_SYSTEM_FONTS");
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let line: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
line["result"].clone()
|
||||
}
|
||||
|
||||
fn families(args: &[&str], opt_out: bool) -> Vec<String> {
|
||||
serde_json::from_value(run_one("families", json!({"command": "text.fonts"}), args, opt_out)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_runs_resolve_installed_families_unless_opted_out() {
|
||||
let built_in = families(&["--no-system-fonts"], false);
|
||||
assert_eq!(families(&[], true), built_in, "FIGCRAFT_NO_SYSTEM_FONTS=1 opts out like the flag");
|
||||
// Any family this machine adds (scanned here, in the test); none (CI): nothing to check.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let Some(installed) = figcraft_text::family_names().into_iter().find(|f| !built_in.contains(f)) else {
|
||||
eprintln!("skipped: no installed fonts beyond the built-in ones");
|
||||
return;
|
||||
};
|
||||
assert!(families(&[], false).contains(&installed), "headless runs list {installed}");
|
||||
let face = json!({"command": "text.fonts", "params": {"family": installed, "axes": true}});
|
||||
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
|
||||
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_the_fonts_a_file_uses_that_are_missing() {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let (script, file) = (d.join("make.json"), d.join("doc.figcraft"));
|
||||
let text =
|
||||
|family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}});
|
||||
std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap();
|
||||
let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let out = cli().arg("info").arg(&file).arg("--json").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}]));
|
||||
|
||||
let out = cli().arg("info").arg(&file).output().unwrap();
|
||||
let shown = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
|
||||
}
|
||||
|
||||
/// A synthetic `.sketch` file: one page with one Helvetica text.
|
||||
const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA=";
|
||||
|
||||
/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to
|
||||
/// `--no-system-fonts` too: the run still knows only the built-in families.
|
||||
#[test]
|
||||
fn sketch_imports_keep_to_no_system_fonts() {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let script = d.join("open.json");
|
||||
let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]);
|
||||
std::fs::write(&script, steps.to_string()).unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
.arg("run")
|
||||
.arg(&script)
|
||||
.arg("--no-system-fonts")
|
||||
.env_remove("FIGCRAFT_NO_SYSTEM_FONTS")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines[0]["ok"], json!(true), "{lines:?}");
|
||||
let fonts: Vec<String> = serde_json::from_value(lines[1]["result"].clone()).unwrap();
|
||||
assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! The crate's integration tests, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod export_depth;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod fonts;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod pdf;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod pdf_print;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod vector_drawable;
|
||||
@@ -1,5 +1,4 @@
|
||||
//! `figcraft-cli export --format pdf` and `convert … .pdf` end to end.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_export_writes_a_drawable_and_prints_its_notes() {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let script = d.join("s.jsonl");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
let file = d.join("doc.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let xml = d.join("icon.xml");
|
||||
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let text = std::fs::read_to_string(&xml).unwrap();
|
||||
assert!(text.contains("<vector") && text.contains("android:fillColor=\"#FF0000\""), "{text}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("note: drop shadows were left out (Dot)"));
|
||||
|
||||
// --format xml into a folder; --vd-compat ignore prints nothing.
|
||||
let dir = d.join("out");
|
||||
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--format", "xml", "--vd-compat", "ignore"]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(dir.join("Dot.xml").exists());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("note:"));
|
||||
|
||||
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).args(["--vd-compat", "sloppy"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--vd-compat"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -120,15 +120,15 @@ impl Abuse {
|
||||
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.
|
||||
/// `ip` tried a join code that was right (`ok`) or wrong: a right one doesn't forgive the wrong
|
||||
/// ones before it (they only decay with time); enough wrong ones 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 {
|
||||
if now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
|
||||
a.wrong = 0;
|
||||
}
|
||||
if !ok {
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::store::Store;
|
||||
use crate::test_util::{TempDir, config};
|
||||
|
||||
fn args(s: &[&str]) -> Vec<String> {
|
||||
s.iter().map(|a| a.to_string()).collect()
|
||||
s.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn run(dir: &std::path::Path, command: Command) -> Result<String, String> {
|
||||
|
||||
@@ -262,17 +262,21 @@ 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.
|
||||
// A right code, even from a neighbour in the same /64, doesn't forgive the wrong ones.
|
||||
let a = ip("2001:db8:1:2::1");
|
||||
let b = ip("2001:db8:1:2::2");
|
||||
for _ in 0..WRONG_CODES - 1 {
|
||||
abuse.code_tried(a, false, t0);
|
||||
}
|
||||
abuse.code_tried(b, true, t0);
|
||||
abuse.code_tried(a, true, t0);
|
||||
assert_eq!(abuse.code_locked(a, t0), None);
|
||||
abuse.code_tried(a, false, t0);
|
||||
assert_eq!(abuse.code_locked(a, t0), Some(CODE_LOCKOUT));
|
||||
// Wrong codes: locked for the lockout, then free again.
|
||||
for _ in 0..WRONG_CODES {
|
||||
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);
|
||||
|
||||
@@ -135,9 +135,23 @@ impl Config {
|
||||
/// abuse contact, or one whose clients would all count as its proxy).
|
||||
pub fn warnings(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let client_max = NetLimits::default().max_server_message >> 20;
|
||||
if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) {
|
||||
out.push(format!(
|
||||
"max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened",
|
||||
self.file_mb(),
|
||||
client_max - 1
|
||||
));
|
||||
}
|
||||
if !self.open_publish {
|
||||
return out;
|
||||
}
|
||||
if self.file_mb() > 64 {
|
||||
out.push(format!(
|
||||
"open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server",
|
||||
self.file_mb()
|
||||
));
|
||||
}
|
||||
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());
|
||||
}
|
||||
@@ -168,7 +182,7 @@ impl Config {
|
||||
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) {
|
||||
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 {
|
||||
@@ -253,8 +267,9 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the
|
||||
/// public this is also the cap (strangers' files stay small); a private server never goes below
|
||||
/// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it.
|
||||
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)
|
||||
}
|
||||
@@ -265,9 +280,9 @@ impl Config {
|
||||
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;
|
||||
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
|
||||
}
|
||||
let max = self.open_message().min(base.max_message_bytes);
|
||||
let max = self.open_message();
|
||||
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
|
||||
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
|
||||
}
|
||||
@@ -279,21 +294,24 @@ impl Config {
|
||||
pub fn net(&self) -> NetLimits {
|
||||
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
let max = base.max_client_message.max(self.open_message());
|
||||
return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base };
|
||||
}
|
||||
let max = self.open_message().min(base.max_client_message);
|
||||
let max = self.open_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);
|
||||
// eighth of each, and its biggest consumers are dropped first. Each still fits one whole
|
||||
// message, or a file as large as `max_file_mb` could never arrive.
|
||||
let one = max.saturating_add(1 << 20);
|
||||
let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8)));
|
||||
NetLimits {
|
||||
max_per_address: self.max_connections_per_ip,
|
||||
max_client_message: max,
|
||||
max_queued_bytes: max.saturating_add(1 << 20),
|
||||
max_queued_bytes: one,
|
||||
max_queued_block: queued / 8,
|
||||
max_queued_total: queued,
|
||||
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
|
||||
max_reading_block: one.min(reading / 8),
|
||||
max_reading_total: reading,
|
||||
max_in_flight: 128 << 20,
|
||||
max_in_flight: (128usize << 20).max(one),
|
||||
reserve_for_sessions: self.max_connections / 8,
|
||||
request_idle: std::time::Duration::from_secs(20),
|
||||
..base
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
|
||||
//!
|
||||
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
|
||||
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
|
||||
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
|
||||
//! whose generation isn't the snapshot's is usually from before a compaction that was cut short
|
||||
//! (what it holds is in the snapshot already), but no checksum covers either generation, so the
|
||||
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
|
||||
//! first damaged record (a write cut short).
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -47,7 +50,7 @@ pub fn free_bytes(path: &Path) -> Option<u64> {
|
||||
|
||||
/// `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();
|
||||
let mut name = path.file_name().map(OsStr::to_os_string).unwrap_or_default();
|
||||
name.push(".tmp");
|
||||
path.with_file_name(name)
|
||||
}
|
||||
@@ -125,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option<u64> {
|
||||
|
||||
/// A snapshot file's bytes.
|
||||
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
|
||||
let body = message::encode(state).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
|
||||
let mut out = Vec::with_capacity(SNAP_HEADER);
|
||||
out.extend_from_slice(SNAP_MAGIC);
|
||||
out.push(FORMAT);
|
||||
out.extend_from_slice(&generation.to_le_bytes());
|
||||
out.extend_from_slice(&crc(&body).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
|
||||
let sum = crc(&out[SNAP_HEADER..]);
|
||||
out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -838,3 +838,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() {
|
||||
assert_eq!(served(fetch(&mut store)), (2, v2));
|
||||
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_publish_cannot_claim_the_highest_version() {
|
||||
let dir = TempDir::new("libmax");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
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 mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone();
|
||||
let mut archive = |version: u64| {
|
||||
lib.manifest.version = version;
|
||||
figcraft_engine::library_server::archive(&lib).unwrap()
|
||||
};
|
||||
let high = 1u64 << 32;
|
||||
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version");
|
||||
assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in");
|
||||
assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it");
|
||||
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused");
|
||||
assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in");
|
||||
}
|
||||
|
||||
@@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() {
|
||||
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_per_address_count_the_wider_ipv6_blocks() {
|
||||
let dir = TempDir::new("block-caps");
|
||||
let mut c = open(dir.path());
|
||||
c.max_files_per_ip = 1;
|
||||
c.publishes_per_hour = 1000;
|
||||
let mut store = Store::open(dir.path(), c.store()).unwrap();
|
||||
// A /48 keeps 4 times one address's files: /64s in different /56s of it.
|
||||
for i in 0..4 {
|
||||
store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
|
||||
}
|
||||
let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err();
|
||||
assert!(e.contains("files on this server already"), "{e}");
|
||||
// A /56 keeps twice one address's files.
|
||||
store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap();
|
||||
store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap();
|
||||
let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err();
|
||||
assert!(e.contains("files on this server already"), "{e}");
|
||||
drop(store);
|
||||
// Bytes: one address keeps one file's worth, so a /48 keeps 4 files.
|
||||
let probe = TempDir::new("block-caps-probe");
|
||||
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
|
||||
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
|
||||
let dir = TempDir::new("block-caps-bytes");
|
||||
let mut s = open(dir.path()).store();
|
||||
s.hosting.max_files_per_ip = 100;
|
||||
s.hosting.max_bytes_per_ip = size;
|
||||
let mut store = Store::open(dir.path(), s).unwrap();
|
||||
for i in 0..4 {
|
||||
store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
|
||||
}
|
||||
let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err();
|
||||
assert!(e.contains("MiB it may keep"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_count_the_wider_ipv6_blocks_too() {
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
let probe = TempDir::new("block-room-probe");
|
||||
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
|
||||
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
|
||||
let dir = TempDir::new("block-room");
|
||||
let mut s = open(dir.path()).store();
|
||||
s.hosting.max_files_per_ip = 100;
|
||||
// One address keeps 2.25 files' worth, so its /56 keeps 4.5.
|
||||
s.hosting.max_bytes_per_ip = size * 2 + size / 4;
|
||||
let mut store = Store::open(dir.path(), s).unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..4 {
|
||||
ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id);
|
||||
}
|
||||
let server = Backend::open(&mut store, &ids[0]).unwrap();
|
||||
// Its own /64 has room for this, but its /56 doesn't.
|
||||
let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err();
|
||||
assert!(e.contains("have reached their"), "{e}");
|
||||
Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_files_published_without_the_key_expire() {
|
||||
let dir = TempDir::new("expiry");
|
||||
@@ -196,6 +254,22 @@ fn idle_files_published_without_the_key_expire() {
|
||||
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_commands_do_not_sweep_idle_files() {
|
||||
let dir = TempDir::new("offline-sweep");
|
||||
let mut c = open(dir.path());
|
||||
c.idle_days = 30.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();
|
||||
store.age(&public.id, 31 * 86_400);
|
||||
drop(store);
|
||||
let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap();
|
||||
drop(store);
|
||||
assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file");
|
||||
let _serving = Store::open(dir.path(), c.store()).unwrap();
|
||||
assert!(!dir.path().join("files").join(&public.id).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limits_and_blocks_refuse_with_a_reason() {
|
||||
let dir = TempDir::new("limits");
|
||||
@@ -413,7 +487,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
|
||||
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");
|
||||
assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin");
|
||||
let w = open.warnings().join("\n");
|
||||
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
|
||||
let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
|
||||
@@ -493,3 +567,34 @@ fn open_connections_never_reach_what_needs_the_key() {
|
||||
assert!(!format!("{info:?}").contains(&k));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_above_64_mib_fit_every_limit() {
|
||||
// `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must
|
||||
// follow it, or the upload is dropped by the transport before the server can say why.
|
||||
for open_publish in [false, true] {
|
||||
let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
|
||||
let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20);
|
||||
assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message);
|
||||
assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}");
|
||||
assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block);
|
||||
assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes);
|
||||
}
|
||||
// A client takes such a file too, but caps what any server may make it buffer at 1 GiB.
|
||||
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_files_warn_the_operator() {
|
||||
let public_url = Some("wss://figcraft.example.com/".into());
|
||||
let warns = |open_publish, mb| {
|
||||
Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join(
|
||||
"
|
||||
",
|
||||
)
|
||||
};
|
||||
assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64));
|
||||
assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65));
|
||||
assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023));
|
||||
assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024));
|
||||
}
|
||||
|
||||
@@ -261,7 +261,9 @@ fn fuzzed_frames_dont_hurt_the_server() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = ws.read();
|
||||
// read() returns Result<Message>, not a lock guard; the fuzz loop ends here on purpose and
|
||||
// the close frame from the server is not interesting.
|
||||
let _ = ws.read(); // NOSONAR
|
||||
}
|
||||
// Still serving, the file intact.
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
|
||||
+48
-1027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
use super::journal::flush_log;
|
||||
use super::*;
|
||||
|
||||
impl Backend for Store {
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
let entry = self.entry(file)?;
|
||||
let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i));
|
||||
link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) })
|
||||
.ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string())
|
||||
}
|
||||
|
||||
fn open(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.load(file).map_err(|e| {
|
||||
let why = format!("file {file} can't be opened: {e}");
|
||||
log::error!("{why}");
|
||||
self.note(why);
|
||||
"this file can't be opened on the server (its data is damaged); tell the server's owner".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn changed(&mut self, file: &str, server: &mut Server) {
|
||||
let journal = server.take_journal();
|
||||
if journal.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes);
|
||||
let Some(e) = self.files.get_mut(file) else { return };
|
||||
let Some(log) = e.log.as_mut() else { return };
|
||||
for j in &journal {
|
||||
match disk::record(j) {
|
||||
Ok(r) => log.pending.extend(r),
|
||||
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
|
||||
}
|
||||
}
|
||||
log.entries += journal.len();
|
||||
log.dirty = true;
|
||||
e.meta.updated = now();
|
||||
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
|
||||
// The snapshot holds what's pending too.
|
||||
self.compact(file, server);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<String> {
|
||||
self.flush_all()
|
||||
}
|
||||
|
||||
fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> {
|
||||
self.room_for(file, server, bytes as u64)
|
||||
}
|
||||
|
||||
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
|
||||
let compact = self.files.get_mut(file).is_some_and(|entry| {
|
||||
let snapshot_seq = entry.meta.seq;
|
||||
entry.log.as_mut().is_some_and(|log| {
|
||||
// Issued client ids and refusals are durable in the log; only edits need a
|
||||
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
|
||||
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
|
||||
})
|
||||
});
|
||||
if compact {
|
||||
self.compact(file, server);
|
||||
}
|
||||
// It closes anyway, with its state in the snapshot and durable log.
|
||||
self.cut_short.retain(|f| f != file);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
// Everyone just left: idle time counts from now.
|
||||
self.touch(file);
|
||||
true
|
||||
}
|
||||
|
||||
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
|
||||
self.write_usage(true);
|
||||
self.flush_all();
|
||||
for (file, server) in open {
|
||||
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
|
||||
self.compact(file, server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manage(&mut self, key: &str) -> Result<(), String> {
|
||||
match same_secret(key, &self.key) {
|
||||
true => Ok(()),
|
||||
false if self.cfg.hosting.open => {
|
||||
Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into())
|
||||
}
|
||||
false => Err("wrong server key".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(&mut self, ip: IpAddr) -> Result<(), String> {
|
||||
if now() >= self.next_sweep {
|
||||
self.sweep();
|
||||
self.write_usage(false);
|
||||
}
|
||||
if self.blocked.contains(&ip) {
|
||||
return Err("this server doesn't accept connections from your address".into());
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Connect, Instant::now()) {
|
||||
return Err("too many connections from your address lately: wait a minute and try again".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool {
|
||||
// Cursors and cursor chat (many people behind one address each send ~30 a second while
|
||||
// they move) have a budget of their own, beside everything else.
|
||||
let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message };
|
||||
self.abuse.allow(ip, what, Instant::now())
|
||||
}
|
||||
|
||||
fn hosting(&mut self, file: &str) -> Option<HostInfo> {
|
||||
Some(self.host_info(Some(file)))
|
||||
}
|
||||
|
||||
fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result<HostInfo, String> {
|
||||
if file.is_empty() && owner.is_empty() {
|
||||
return Ok(self.host_info(None));
|
||||
}
|
||||
if !self.owns(file, owner) {
|
||||
return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into());
|
||||
}
|
||||
Ok(self.host_info(Some(file)))
|
||||
}
|
||||
|
||||
fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() });
|
||||
let file = match &req {
|
||||
Admin::Publish { name, snapshot } => {
|
||||
return match self.publish_open(ip, name, snapshot) {
|
||||
Ok((file, edit, view, owner)) => {
|
||||
let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta));
|
||||
AdminReply::Hosted { file, edit, view, owner, expires }.into()
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("event=publish_refused ip={ip} reason={e:?}");
|
||||
Handled::from(AdminReply::Failed { reason: e })
|
||||
}
|
||||
};
|
||||
}
|
||||
Admin::Owned { files } => return self.owned(files).into(),
|
||||
Admin::Links { file }
|
||||
| Admin::CreateLink { file, .. }
|
||||
| Admin::Revoke { file, .. }
|
||||
| Admin::Rename { file, .. }
|
||||
| Admin::Code { file, .. }
|
||||
| Admin::Remove { file } => file,
|
||||
_ => return failed("that needs the server key"),
|
||||
};
|
||||
if owns != Some(file.as_str()) {
|
||||
return failed("an owner token manages only its own file");
|
||||
}
|
||||
self.admin(req, open)
|
||||
}
|
||||
|
||||
fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
|
||||
self.keep_report(file, link, ip, reason)
|
||||
}
|
||||
|
||||
fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
|
||||
match req {
|
||||
Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) {
|
||||
Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(),
|
||||
Err(e) => failed(e),
|
||||
},
|
||||
Admin::Files => AdminReply::Files(self.list(open)).into(),
|
||||
Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()),
|
||||
Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()),
|
||||
Admin::Revoke { file, link } => match self.revoke(&file, &link) {
|
||||
Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None },
|
||||
Err(e) => failed(e),
|
||||
},
|
||||
Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()),
|
||||
Admin::Listing => AdminReply::Listing(self.listing(open)).into(),
|
||||
Admin::Reports => AdminReply::Reports(self.reports.clone()).into(),
|
||||
Admin::Block { ip } => match self.block(&ip, true) {
|
||||
Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) },
|
||||
Err(e) => failed(e),
|
||||
},
|
||||
Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()),
|
||||
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
|
||||
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
|
||||
Admin::Owned { files } => self.owned(&files).into(),
|
||||
// The hub removes people itself (it knows who is connected); it never asks the store.
|
||||
Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
|
||||
Admin::Remove { file } => {
|
||||
if !self.files.contains_key(&file) {
|
||||
return failed("this file isn't on the server (it may have been removed)".into());
|
||||
}
|
||||
let reply = match self.remove(&file) {
|
||||
Ok(()) => AdminReply::Done,
|
||||
Err(reason) => AdminReply::Failed { reason },
|
||||
};
|
||||
Handled { reply, revoked: None, removed: Some(file), blocked: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply {
|
||||
let failed = |reason: String| LibraryReply::Failed { reason };
|
||||
match req {
|
||||
LibraryReq::Fetch { have, usage } => {
|
||||
if let Some(u) = usage
|
||||
&& let Err(e) = self.report_usage(file, u)
|
||||
{
|
||||
log::warn!("file {file}: a library usage report couldn't be kept: {e}");
|
||||
}
|
||||
self.fetch_library(ip, file, have).unwrap_or_else(failed)
|
||||
}
|
||||
LibraryReq::Publish { .. } if access != Access::Edit => {
|
||||
failed("you can only view this file: publishing its library needs an edit link".into())
|
||||
}
|
||||
LibraryReq::Publish { archive } => {
|
||||
self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version })
|
||||
}
|
||||
LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply {
|
||||
// One address's share first (before everyone's budget is spent or anything is read).
|
||||
if let PluginReq::Fetch { id, version } = &req
|
||||
&& let Some(size) = self.directory.size_of(id, version.as_deref())
|
||||
&& !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now())
|
||||
{
|
||||
return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() };
|
||||
}
|
||||
self.directory.handle(publisher, req)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> {
|
||||
self.resolve_code(ip, code)
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Restarting
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
|
||||
pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> {
|
||||
self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
|
||||
}
|
||||
|
||||
/// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
|
||||
/// caps it gets, and the files and bytes kept by the files published from addresses in it. An
|
||||
/// address that doesn't parse counts as itself only, ×1, by its exact text.
|
||||
pub(super) fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
|
||||
let blocks_of = |text: &str| text.parse::<IpAddr>().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
|
||||
let kept: Vec<(&str, Vec<_>, u64)> = self
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|(id, e)| {
|
||||
let from = e.meta.ip.as_deref()?;
|
||||
Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
|
||||
})
|
||||
.collect();
|
||||
let sum = |mine: &mut dyn Iterator<Item = &(&str, Vec<_>, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
|
||||
let blocks = blocks_of(ip);
|
||||
if blocks.is_empty() {
|
||||
let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
|
||||
return vec![(1, files, bytes)];
|
||||
}
|
||||
blocks
|
||||
.into_iter()
|
||||
.map(|(block, times)| {
|
||||
let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
|
||||
(times, files, bytes)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep a document published without the key from `ip`, within the caps on what one address
|
||||
/// and everyone may keep: the file, its links and its owner token.
|
||||
pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
|
||||
let h = &self.cfg.hosting;
|
||||
let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes);
|
||||
if !open {
|
||||
return Err(KEY_ONLY.into());
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
|
||||
return Err("too many files published from your address lately: try again in an hour".into());
|
||||
}
|
||||
let blocks = self.per_block(&ip.to_string());
|
||||
for &(times, files, _) in &blocks {
|
||||
if files >= max_files.saturating_mul(times as usize) {
|
||||
return Err(format!(
|
||||
"your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
|
||||
));
|
||||
}
|
||||
}
|
||||
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
|
||||
let fits = |n: u64| {
|
||||
for &(times, _, used) in &blocks {
|
||||
let cap = max_ip.saturating_mul(u64::from(times));
|
||||
if used.saturating_add(n) > cap {
|
||||
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
|
||||
}
|
||||
}
|
||||
if used_all.saturating_add(n) > max_all {
|
||||
return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?;
|
||||
Ok((info, edit, view, owner.unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Keep a document as a new file: published without the key from `ip` (it gets an owner
|
||||
/// token), else by the operator. `fits` checks its size against the caps that apply.
|
||||
fn keep(
|
||||
&mut self,
|
||||
name: &str,
|
||||
snapshot: &[Change],
|
||||
ip: Option<IpAddr>,
|
||||
fits: &dyn Fn(u64) -> Result<(), String>,
|
||||
) -> Result<(FileInfo, NewLink, NewLink, Option<String>), String> {
|
||||
if self.files.len() >= self.cfg.max_files {
|
||||
return Err(format!("the server keeps at most {} files", self.cfg.max_files));
|
||||
}
|
||||
if let Some(why) = self.low_disk() {
|
||||
return Err(why);
|
||||
}
|
||||
let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?;
|
||||
if replica.document().node_count() > self.cfg.core.max_nodes {
|
||||
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
|
||||
}
|
||||
let mut doc = replica.document().clone();
|
||||
let name = match clean_name(name) {
|
||||
n if n.is_empty() => doc.name.clone(),
|
||||
n => n,
|
||||
};
|
||||
doc.name = name.clone();
|
||||
let server = Server::new(doc, "", self.cfg.core);
|
||||
let state = server.state().map_err(|e| e.to_string())?;
|
||||
let bytes = disk::encode_snapshot(1, &state)?;
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20));
|
||||
}
|
||||
fits(bytes.len() as u64)?;
|
||||
let id = loop {
|
||||
let id = token::file_id()?;
|
||||
if !self.files.contains_key(&id) && !self.dir(&id).exists() {
|
||||
break id;
|
||||
}
|
||||
};
|
||||
let t = now();
|
||||
let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
|
||||
let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
|
||||
let owner = ip.map(|_| token::secret()).transpose()?;
|
||||
let meta =
|
||||
Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 };
|
||||
let dir = self.dir(&id);
|
||||
let written = fs::create_dir_all(&dir)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|()| {
|
||||
disk::sync_dir(Some(&self.root.join(FILES)));
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())
|
||||
})
|
||||
.and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
|
||||
.and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
|
||||
// Last: a folder without its description is an upload cut short.
|
||||
.and_then(|()| write_json(&dir.join(META), &meta));
|
||||
if let Err(e) = written {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
return Err(format!("couldn't keep the file: {e}"));
|
||||
}
|
||||
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
|
||||
self.files.insert(id.clone(), entry);
|
||||
let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
|
||||
match ip {
|
||||
Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()),
|
||||
None => log::info!("event=published file={id} bytes={} by=key", bytes.len()),
|
||||
}
|
||||
Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner))
|
||||
}
|
||||
|
||||
/// The files kept, with what the operator moderates by.
|
||||
pub fn listing(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileRow> {
|
||||
self.files
|
||||
.iter()
|
||||
.map(|(id, e)| FileRow {
|
||||
info: self.info(id, e, open(id)),
|
||||
opened: e.meta.opened,
|
||||
expires: self.expires(&e.meta),
|
||||
open: e.meta.owner.is_some(),
|
||||
ip: e.meta.ip.clone().unwrap_or_default(),
|
||||
reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Owner token `owner` manages file `file` (one published without the key).
|
||||
pub(super) fn owns(&self, file: &str, owner: &str) -> bool {
|
||||
let presented = hash(owner);
|
||||
self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h))
|
||||
}
|
||||
|
||||
/// What became of the files these `(file id, owner token)` pairs own: each one's name, links
|
||||
/// and expiry, or `None` when that token manages no file of that id here (which doesn't say
|
||||
/// whether the id exists). Asking opens nothing: it doesn't count as using a file.
|
||||
pub fn owned(&self, files: &[(String, String)]) -> AdminReply {
|
||||
if files.len() > MAX_OWNED {
|
||||
return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") };
|
||||
}
|
||||
let one = |(file, owner): &(String, String)| {
|
||||
let e = self.files.get(file).filter(|_| self.owns(file, owner))?;
|
||||
Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) })
|
||||
};
|
||||
AdminReply::Owned(files.iter().map(one).collect())
|
||||
}
|
||||
|
||||
/// Rename a file (its listing; the document inside keeps its own name).
|
||||
pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> {
|
||||
let name = clean_name(name);
|
||||
if name.is_empty() {
|
||||
return Err("give the file a name".into());
|
||||
}
|
||||
let path = self.dir(file).join(META);
|
||||
let entry = self.entry(file)?;
|
||||
let before = std::mem::replace(&mut entry.meta.name, name);
|
||||
if let Err(e) = write_json(&path, &entry.meta) {
|
||||
entry.meta.name = before;
|
||||
return Err(format!("couldn't rename the file: {e}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the files published without the key that nobody opened for the idle time (not
|
||||
/// those open now); their ids.
|
||||
pub fn sweep(&mut self) -> Vec<String> {
|
||||
let t = now();
|
||||
self.next_sweep = t.saturating_add(SWEEP_EVERY);
|
||||
let due: Vec<String> =
|
||||
self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
|
||||
for id in &due {
|
||||
match self.remove(id) {
|
||||
Ok(()) => log::info!("event=expired file={id}"),
|
||||
Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"),
|
||||
}
|
||||
}
|
||||
due
|
||||
}
|
||||
|
||||
/// Make a file look unused for `secs` more (tests: expiry without waiting).
|
||||
#[cfg(test)]
|
||||
pub fn age(&mut self, file: &str, secs: u64) {
|
||||
let path = self.dir(file).join(META);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
let m = &mut e.meta;
|
||||
(m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
|
||||
let _ = write_json(&path, m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make an open file's log refuse writes (tests: a full or failing disk).
|
||||
#[cfg(test)]
|
||||
pub fn break_log(&mut self, file: &str) {
|
||||
let path = self.dir(file).join(LOG);
|
||||
if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
|
||||
&& let Ok(f) = File::open(path)
|
||||
{
|
||||
log.file = f;
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that a file was opened now (it is used: it doesn't expire).
|
||||
pub(super) fn touch(&mut self, file: &str) {
|
||||
let path = self.dir(file).join(META);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.meta.opened = now();
|
||||
if let Err(err) = write_json(&path, &e.meta) {
|
||||
log::warn!("file {file}: couldn't update {META}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the server says about its hosting (with `file`'s expiry).
|
||||
pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo {
|
||||
let h = &self.cfg.hosting;
|
||||
let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta));
|
||||
HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires }
|
||||
}
|
||||
|
||||
/// Block or unblock an address (an IPv6 address: its /64); the address as kept.
|
||||
pub fn block(&mut self, ip: &str, on: bool) -> Result<IpAddr, String> {
|
||||
let ip = ip_key(ip.trim().parse::<IpAddr>().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?);
|
||||
if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED {
|
||||
return Err(format!("at most {MAX_BLOCKED} addresses can be blocked"));
|
||||
}
|
||||
let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) };
|
||||
if changed {
|
||||
let list: Vec<String> = self.blocked.iter().map(IpAddr::to_string).collect();
|
||||
write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?;
|
||||
log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" });
|
||||
}
|
||||
Ok(ip)
|
||||
}
|
||||
|
||||
/// The blocked addresses.
|
||||
pub fn blocked(&self) -> Vec<String> {
|
||||
self.blocked.iter().map(IpAddr::to_string).collect()
|
||||
}
|
||||
|
||||
/// The reports kept, oldest first.
|
||||
pub fn reports(&self) -> &[ReportInfo] {
|
||||
&self.reports
|
||||
}
|
||||
|
||||
/// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
|
||||
pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
|
||||
let reason = clean_reason(reason);
|
||||
if reason.is_empty() {
|
||||
return Err("say what is wrong with the file".into());
|
||||
}
|
||||
let name = self.entry(file)?.meta.name.clone();
|
||||
if !self.abuse.allow(ip, Action::Report, Instant::now()) {
|
||||
return Err("too many reports from your address lately: try again in an hour".into());
|
||||
}
|
||||
self.reports.push(ReportInfo {
|
||||
at: now(),
|
||||
file: file.to_string(),
|
||||
name,
|
||||
link: link.unwrap_or_default().to_string(),
|
||||
ip: ip.to_string(),
|
||||
reason,
|
||||
});
|
||||
if self.reports.len() > MAX_REPORTS {
|
||||
let extra = self.reports.len() - MAX_REPORTS;
|
||||
self.reports.drain(..extra);
|
||||
}
|
||||
write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?;
|
||||
log::info!("event=report file={file} ip={ip}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
/// Whether `file` has room for about `n` more bytes: its cap, and for a file published
|
||||
/// without the key its address's share and everyone's. A log that took it over its cap is
|
||||
/// compacted first (the state may be smaller than its history).
|
||||
pub(super) fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> {
|
||||
if let Some(why) = self.low_disk() {
|
||||
return Err(why);
|
||||
}
|
||||
let max = self.cfg.max_file_bytes;
|
||||
let over = |s: &Store| s.bytes(file).saturating_add(n) > max;
|
||||
// A compaction to make room at most once a minute per file (it rewrites the whole file).
|
||||
let t = now();
|
||||
let may_compact = self.files.get(file).is_some_and(|e| {
|
||||
t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty())
|
||||
});
|
||||
if over(self) && may_compact {
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.forced = t;
|
||||
}
|
||||
self.compact(file, server);
|
||||
}
|
||||
if over(self) {
|
||||
return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20));
|
||||
}
|
||||
let h = &self.cfg.hosting;
|
||||
let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
|
||||
let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
|
||||
for (times, _, used) in self.per_block(&ip) {
|
||||
let cap = max_ip.saturating_mul(u64::from(times));
|
||||
if used.saturating_add(n) > cap {
|
||||
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
|
||||
}
|
||||
}
|
||||
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
|
||||
if used_all.saturating_add(n) > max_all {
|
||||
return Err("this server is full: that change wasn't kept".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
|
||||
pub(super) fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.touch(file);
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
let mut issues = Vec::new();
|
||||
let mut fresh_log = true;
|
||||
let mut kept = 0;
|
||||
let log_path = dir.join(LOG);
|
||||
// A log that's there but can't be read (an IO error, or over a lowered cap) may hold
|
||||
// confirmed edits: the file isn't opened, and the log is left as it is for another try.
|
||||
let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) {
|
||||
Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")),
|
||||
r => r,
|
||||
};
|
||||
// One that isn't a log is moved aside before a new one replaces it (or the file isn't opened).
|
||||
let read = match read.map(|b| disk::read_log(&b)) {
|
||||
Ok(Err(why)) => {
|
||||
let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now()));
|
||||
fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?;
|
||||
Err(format!("{why}; it was moved aside to {}", aside.display()))
|
||||
}
|
||||
Ok(Ok(log)) => Ok(log),
|
||||
Err(why) => Err(why),
|
||||
};
|
||||
match read {
|
||||
Ok(log) if log.generation == generation => {
|
||||
fresh_log = false;
|
||||
for entry in log.entries {
|
||||
if let Err(e) = server.replay(entry) {
|
||||
issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped"));
|
||||
fresh_log = true;
|
||||
break;
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
if let Some(why) = log.damaged {
|
||||
issues.push(format!("file {file}: its log ended with {why}; that part was dropped"));
|
||||
fresh_log = true;
|
||||
}
|
||||
}
|
||||
// From before a compaction that was cut short, the snapshot has it all; or a generation
|
||||
// field damaged, which no checksum covers. Replaying tells them apart without trusting
|
||||
// it: entries the snapshot holds are skipped, so only edits it lacks change anything.
|
||||
Ok(log) => {
|
||||
let before = server.seq();
|
||||
let mut stopped = None;
|
||||
for entry in log.entries {
|
||||
if let Err(e) = server.replay(entry) {
|
||||
stopped = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
|
||||
if recovered > 0 {
|
||||
issues.push(format!(
|
||||
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
|
||||
{recovered} changes the snapshot didn't; they were recovered"
|
||||
));
|
||||
}
|
||||
if let Some(e) = stopped {
|
||||
issues.push(format!(
|
||||
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
|
||||
making sense after {recovered} newer changes ({e}); the rest was dropped"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
|
||||
}
|
||||
for i in issues {
|
||||
log::warn!("{i}");
|
||||
self.note(i);
|
||||
}
|
||||
server.journaling(true);
|
||||
let file_handle = OpenOptions::new().append(true).open(dir.join(LOG));
|
||||
let log = match (fresh_log, file_handle) {
|
||||
(false, Ok(f)) => {
|
||||
let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len());
|
||||
// Replayed entries make it worth compacting once everyone has left.
|
||||
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
|
||||
}
|
||||
// A new snapshot and log of the next generation, so appends start clean.
|
||||
_ => {
|
||||
let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
|
||||
self.rewrite(file, next, &server).map_err(|e| e.why)?
|
||||
}
|
||||
};
|
||||
self.entry(file)?.log = Some(log);
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
|
||||
/// document (recent batches for reconnecting clients, deletions), which may take it over.
|
||||
pub(super) fn snapshot_cap(&self) -> u64 {
|
||||
self.cfg.max_file_bytes.saturating_mul(2)
|
||||
}
|
||||
|
||||
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
|
||||
let dir = self.dir(file);
|
||||
let kept = |why: String| RewriteError { why, snapshot_replaced: false };
|
||||
let state = server.state().map_err(|e| kept(e.to_string()))?;
|
||||
let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
|
||||
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
|
||||
// and log still hold everything.
|
||||
if bytes.len() as u64 > self.snapshot_cap() {
|
||||
return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
|
||||
}
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
|
||||
}
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
|
||||
// From here the old log no longer goes with the snapshot on disk.
|
||||
let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
|
||||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.bytes = (bytes.len() + LOG_HEADER) as u64;
|
||||
e.meta.seq = server.seq();
|
||||
let meta = e.meta.clone();
|
||||
if let Err(err) = write_json(&dir.join(META), &meta) {
|
||||
log::warn!("file {file}: couldn't update {META}: {err}");
|
||||
}
|
||||
}
|
||||
Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false })
|
||||
}
|
||||
|
||||
/// Compactions so far.
|
||||
pub fn compactions(&self) -> u64 {
|
||||
self.compactions
|
||||
}
|
||||
|
||||
/// Why nothing more may be written when the data directory's disk is nearly full (less free
|
||||
/// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space
|
||||
/// can't be told, nothing is refused.
|
||||
pub(super) fn low_disk(&mut self) -> Option<String> {
|
||||
let now = Instant::now();
|
||||
let free = match self.free {
|
||||
Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free,
|
||||
_ => {
|
||||
let free = disk::free_bytes(&self.root);
|
||||
self.free = Some((now, free));
|
||||
free
|
||||
}
|
||||
};
|
||||
free.filter(|f| *f < self.cfg.min_free_bytes)
|
||||
.map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string())
|
||||
}
|
||||
|
||||
/// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept.
|
||||
pub(super) fn note(&mut self, problem: String) {
|
||||
if self.problems.len() >= MAX_PROBLEMS {
|
||||
self.problems.remove(0);
|
||||
}
|
||||
self.problems.push(problem);
|
||||
}
|
||||
|
||||
/// Compact an open file: its state as the next snapshot, an empty log.
|
||||
pub(super) fn compact(&mut self, file: &str, server: &Server) {
|
||||
self.compactions += 1;
|
||||
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
|
||||
let Some(next) = generation.checked_add(1) else {
|
||||
log::error!("file {file}: compaction failed: the file's generation can't go any higher");
|
||||
return;
|
||||
};
|
||||
match self.rewrite(file, next, server) {
|
||||
Ok(log) => {
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.log = Some(log);
|
||||
}
|
||||
log::debug!("compacted file {file}");
|
||||
}
|
||||
// The new snapshot holds everything, but a restart would drop whatever is appended to
|
||||
// the old log now (its generation no longer matches): the file closes, the hub hears
|
||||
// it at the next flush, and it opens again from the new snapshot.
|
||||
Err(RewriteError { why, snapshot_replaced: true }) => {
|
||||
self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
|
||||
log::error!("file {file}: compaction was cut short: {why}");
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
self.cut_short.push(file.to_string());
|
||||
}
|
||||
// The old snapshot and log still hold everything: carry on appending.
|
||||
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write and sync what's pending in every open log; the files whose log couldn't be written.
|
||||
/// Those are closed here (their pending records dropped: nobody was told of them) and open
|
||||
/// again from what is on disk.
|
||||
pub(super) fn flush_all(&mut self) -> Vec<String> {
|
||||
let mut failed = std::mem::take(&mut self.cut_short);
|
||||
for (id, e) in &mut self.files {
|
||||
if let Some(log) = e.log.as_mut() {
|
||||
match flush_log(id, log) {
|
||||
Ok(n) => e.bytes += n,
|
||||
Err(()) => {
|
||||
e.log = None;
|
||||
failed.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
}
|
||||
|
||||
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
|
||||
/// can't be trusted to hold them).
|
||||
pub(super) fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
if log.pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
|
||||
Ok(()) => {
|
||||
let n = log.pending.len() as u64;
|
||||
log.bytes += n;
|
||||
log.pending.clear();
|
||||
Ok(n)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("file {id}: couldn't write its log: {e}");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
/// A file's team library for `ip`, unless its version is `have`; within what one address and
|
||||
/// everyone may download (the size is known before anything is read).
|
||||
pub(super) fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
|
||||
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
|
||||
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
|
||||
if have == Some(lib.version) {
|
||||
return Ok(LibraryReply::Unchanged { version: lib.version });
|
||||
}
|
||||
let path = library_file(&dir, lib.version);
|
||||
let size = fs::metadata(&path).map_or(0, |m| m.len());
|
||||
let now = Instant::now();
|
||||
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
|
||||
return Err(BUSY_DOWNLOADS.into());
|
||||
}
|
||||
Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
|
||||
}
|
||||
|
||||
/// Keep `archive` as a file's team library: it must read as a library (with the limits files
|
||||
/// using it read it with), be the file's library if it has one, and be a newer version.
|
||||
// shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile);
|
||||
// move it to a worker if large libraries make that noticeable.
|
||||
pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result<u64, String> {
|
||||
let max = self.cfg.max_file_bytes;
|
||||
if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() {
|
||||
return Err(NO_PUBLIC_LIBRARY.into());
|
||||
}
|
||||
if archive.len() as u64 > max {
|
||||
return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20));
|
||||
}
|
||||
// Everything it unpacks to counts against the same cap, so a small archive can't expand
|
||||
// into a large library in memory.
|
||||
let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => return Err("that isn't a published library".into()),
|
||||
Err(e) => return Err(format!("the library can't be read: {e}")),
|
||||
};
|
||||
if lib.doc.node_count() > self.cfg.core.max_nodes {
|
||||
return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes));
|
||||
}
|
||||
let m = &lib.manifest;
|
||||
let dir = self.dir(file);
|
||||
let entry = self.entry(file)?;
|
||||
if let Some(old) = &entry.meta.library {
|
||||
if old.id != m.id {
|
||||
return Err(format!("this file's library on the server is another one (\"{}\")", old.name));
|
||||
}
|
||||
if m.version <= old.version {
|
||||
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
|
||||
}
|
||||
// A jump would let a client use up the numbers and leave no newer version to publish.
|
||||
if m.version > old.version.saturating_add(1) {
|
||||
return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
|
||||
}
|
||||
} else if m.version > MAX_FIRST_LIBRARY_VERSION {
|
||||
return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
|
||||
}
|
||||
// Each version has a file of its own, written before the description names it: a failed
|
||||
// description write or a crash in between leaves the previous version served whole, never
|
||||
// the new bytes under the old number.
|
||||
let path = dir.join(library_name(m.version));
|
||||
disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
|
||||
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
|
||||
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
|
||||
// Never served (the description doesn't name it); the next publish of it replaces it.
|
||||
if let Err(r) = fs::remove_file(&path) {
|
||||
log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
|
||||
}
|
||||
entry.meta.library = before;
|
||||
return Err(format!("couldn't keep the library: {e}"));
|
||||
}
|
||||
// The previous version isn't served any more.
|
||||
if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
|
||||
log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
|
||||
}
|
||||
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
|
||||
Ok(m.version)
|
||||
}
|
||||
|
||||
/// A library's usage reports in memory (read from disk the first time; a damaged list starts
|
||||
/// over: it is only statistics). `None` when the file has no library.
|
||||
fn usage_of(&mut self, file: &str) -> Result<Option<&mut UsageCache>, String> {
|
||||
if self.entry(file)?.meta.library.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.usage.contains_key(file) {
|
||||
let path = self.dir(file).join(USAGE);
|
||||
let list = if path.exists() {
|
||||
disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() });
|
||||
}
|
||||
Ok(self.usage.get_mut(file))
|
||||
}
|
||||
|
||||
/// The usage reports kept for a file's library (none when it has no library).
|
||||
pub fn library_usage(&mut self, file: &str) -> Result<Vec<FileUsage>, String> {
|
||||
Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Keep how one file uses a file's library (in memory; written when it changed, at most every
|
||||
/// [`USAGE_WRITE_EVERY`] seconds).
|
||||
pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> {
|
||||
let name = clean_name(&usage.file);
|
||||
if name.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
|
||||
components.sort();
|
||||
components.dedup_by_key(|c| c.0);
|
||||
components.truncate(MAX_REPORTED);
|
||||
let Some(u) = self.usage_of(file)? else { return Ok(()) };
|
||||
match u.list.iter_mut().find(|f| f.usage.file == name) {
|
||||
Some(f) if f.usage.components == components => return Ok(()),
|
||||
Some(f) => {
|
||||
f.usage.components = components;
|
||||
f.updated = now();
|
||||
}
|
||||
None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
|
||||
}
|
||||
if u.list.len() > MAX_USERS {
|
||||
u.list.sort_by_key(|f| std::cmp::Reverse(f.updated));
|
||||
u.list.truncate(MAX_USERS);
|
||||
}
|
||||
u.dirty = true;
|
||||
if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) {
|
||||
self.write_usage(false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the usage reports that changed (`all`: whenever they were last written).
|
||||
pub(super) fn write_usage(&mut self, all: bool) {
|
||||
let t = now();
|
||||
let root = self.root.join(FILES);
|
||||
for (file, u) in &mut self.usage {
|
||||
if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) {
|
||||
if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) {
|
||||
log::warn!("file {file}: library usage couldn't be kept: {e}");
|
||||
}
|
||||
(u.dirty, u.written) = (false, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the file holding version `version` of a file's team library.
|
||||
pub fn library_name(version: u64) -> String {
|
||||
format!("library-{version}.bin")
|
||||
}
|
||||
|
||||
/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
|
||||
/// version published before each version had one.
|
||||
fn library_file(dir: &Path, version: u64) -> PathBuf {
|
||||
let path = dir.join(library_name(version));
|
||||
if path.exists() { path } else { dir.join(LIBRARY) }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
/// The files kept.
|
||||
pub fn list(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileInfo> {
|
||||
self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect()
|
||||
}
|
||||
|
||||
/// A file's links.
|
||||
pub fn links(&mut self, file: &str) -> Result<Vec<LinkInfo>, String> {
|
||||
Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect())
|
||||
}
|
||||
|
||||
/// A new link to a file.
|
||||
pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result<NewLink, String> {
|
||||
let max = self.cfg.max_links;
|
||||
let path = self.dir(file).join(LINKS);
|
||||
let entry = self.entry(file)?;
|
||||
if entry.links.len() >= max {
|
||||
return Err(format!("a file has at most {max} links: revoke one first"));
|
||||
}
|
||||
let (link, token) = new_link(access, &clean_name(label), now())?;
|
||||
entry.links.push(link.clone());
|
||||
if let Err(e) = write_json(&path, &entry.links) {
|
||||
entry.links.pop();
|
||||
return Err(format!("couldn't keep the link: {e}"));
|
||||
}
|
||||
Ok(NewLink { link: link.info(file), token })
|
||||
}
|
||||
|
||||
/// The link of `entry` whose token `token` is (every link is compared, in constant time, so
|
||||
/// timing doesn't tell which one matched).
|
||||
pub(super) fn link_of(entry: &Entry, token: &str) -> Option<usize> {
|
||||
let presented = hash(token);
|
||||
let mut found = None;
|
||||
for (i, l) in entry.links.iter().enumerate() {
|
||||
if same_secret(&presented, &l.hash) {
|
||||
found = Some(i);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// These join codes no longer open anything: typing one says so.
|
||||
fn forget_codes(&mut self, codes: impl IntoIterator<Item = Sealed>) {
|
||||
self.gone_codes.extend(codes.into_iter().map(|c| c.hash));
|
||||
let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES);
|
||||
self.gone_codes.drain(..extra);
|
||||
}
|
||||
|
||||
/// Where the link with the join code `key` is: its file, its place among the file's links
|
||||
/// and its token (every code is compared, so timing doesn't tell which one matched).
|
||||
fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> {
|
||||
let mut found = None;
|
||||
for (id, e) in &self.files {
|
||||
for (i, l) in e.links.iter().enumerate() {
|
||||
if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) {
|
||||
found = Some((id.clone(), i, token));
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// A new join code for the link of `file` whose token is `token` (it takes the place of the
|
||||
/// link's earlier code): the code, shown this once.
|
||||
pub fn set_code(&mut self, file: &str, token: &str) -> Result<String, String> {
|
||||
let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?;
|
||||
// 45 random bits: one already in use is all but impossible, and never handed out twice.
|
||||
let (code, key) = loop {
|
||||
let code = codes::new_code()?;
|
||||
let key = codes::Key::new(&code);
|
||||
if self.find_code(&key).is_none() {
|
||||
break (code, key);
|
||||
}
|
||||
};
|
||||
let sealed = key.seal(token)?;
|
||||
let path = self.dir(file).join(LINKS);
|
||||
let entry = self.entry(file)?;
|
||||
let link = entry.links.get_mut(at).ok_or("that file has no such link")?;
|
||||
let before = link.code.replace(sealed);
|
||||
let id = link.id.clone();
|
||||
if let Err(e) = write_json(&path, &entry.links) {
|
||||
if let Some(l) = entry.links.get_mut(at) {
|
||||
l.code = before;
|
||||
}
|
||||
return Err(format!("couldn't keep the join code: {e}"));
|
||||
}
|
||||
self.forget_codes(before);
|
||||
log::info!("event=code file={file} link={id}");
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// The file a join code typed at `ip` opens, and its link's token; wrong codes count against
|
||||
/// the address (see `abuse`), and nothing is looked up for an address that is locked out.
|
||||
pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> {
|
||||
let now = Instant::now();
|
||||
if let Some(left) = self.abuse.code_locked(ip, now) {
|
||||
return Err(format!(
|
||||
"Too many wrong codes from your address: try again in {} minutes, or join with the invite link",
|
||||
left.as_secs() / 60 + 1
|
||||
));
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Code, now) {
|
||||
return Err("Too many codes tried from your address: wait a minute and try again".into());
|
||||
}
|
||||
if !self.abuse.codes_open(now) {
|
||||
return Err(CODES_BUSY.into());
|
||||
}
|
||||
// Anything that isn't a code's symbols is a wrong code too (and costs no hashing).
|
||||
let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c));
|
||||
let found = key.as_ref().and_then(|k| self.find_code(k));
|
||||
self.abuse.code_tried(ip, found.is_some(), now);
|
||||
match found {
|
||||
Some((file, _, token)) => {
|
||||
log::info!("event=code_used file={file} ip={ip}");
|
||||
Ok((file, token))
|
||||
}
|
||||
None => {
|
||||
let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash()));
|
||||
Err(if gone { CODE_GONE } else { NO_CODE }.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke a link.
|
||||
pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> {
|
||||
let path = self.dir(file).join(LINKS);
|
||||
let entry = self.entry(file)?;
|
||||
let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?;
|
||||
let gone = entry.links.remove(at);
|
||||
if let Err(e) = write_json(&path, &entry.links) {
|
||||
entry.links.insert(at, gone);
|
||||
return Err(format!("couldn't revoke the link: {e}"));
|
||||
}
|
||||
log::info!("revoked link {link} of file {file}");
|
||||
self.forget_codes(gone.code);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no
|
||||
/// longer served either way).
|
||||
pub fn remove(&mut self, file: &str) -> Result<(), String> {
|
||||
// Closed first: an open log would keep the folder from being deleted on Windows.
|
||||
let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?;
|
||||
self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone()));
|
||||
drop(entry);
|
||||
let dir = self.dir(file);
|
||||
fs::remove_dir_all(&dir)
|
||||
.map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?;
|
||||
log::info!("removed file {file}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() {
|
||||
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
|
||||
let dir = TempDir::new("read-only-reconnects");
|
||||
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
|
||||
let (id, original) = {
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Read only");
|
||||
let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap();
|
||||
(id, bytes)
|
||||
};
|
||||
let mut last_client = 0;
|
||||
for _ in 0..6 {
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let client = join(&mut server, Access::View);
|
||||
assert!(client.id() > last_client, "issued ids survive closing and reopening the store");
|
||||
last_client = client.id();
|
||||
store.changed(&id, &mut server);
|
||||
// Idle must sync even if no periodic flush ran before the connection closed.
|
||||
assert!(store.idle(&id, &mut server));
|
||||
assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot");
|
||||
assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original);
|
||||
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
|
||||
assert!(!log.entries.is_empty());
|
||||
assert_eq!(server.seq(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_log_is_compacted_while_open() {
|
||||
let dir = TempDir::new("compact");
|
||||
@@ -229,6 +257,17 @@ fn one_server_per_data_directory() {
|
||||
assert_eq!(again.key(), key, "the key is kept");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_existing_data_directory_is_made_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new("perms");
|
||||
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(crate::store::lock(dir.path()).unwrap().is_some());
|
||||
let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_directories_are_removed() {
|
||||
let path = {
|
||||
@@ -241,6 +280,19 @@ fn temporary_directories_are_removed() {
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_encoding_keeps_the_existing_framing_and_checksum() {
|
||||
let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap();
|
||||
let body = figcraft_collab::message::encode(&state).unwrap();
|
||||
let mut legacy = b"FCSN\x01".to_vec();
|
||||
legacy.extend_from_slice(&37u64.to_le_bytes());
|
||||
legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
|
||||
legacy.extend_from_slice(&body);
|
||||
let encoded = disk::encode_snapshot(37, &state).unwrap();
|
||||
assert_eq!(encoded, legacy);
|
||||
assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic_the_readers() {
|
||||
let mut rng = Rng::new(7);
|
||||
@@ -313,3 +365,129 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
|
||||
let name = page_name(&restored);
|
||||
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_that_cant_be_read_is_never_overwritten() {
|
||||
let dir = TempDir::new("unreadlog");
|
||||
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
|
||||
let log_path = dir.path().join(FILES);
|
||||
let last = "Confirmed page name number 39 with some padding";
|
||||
let id = {
|
||||
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);
|
||||
store.changed(&id, &mut server);
|
||||
for i in 0..40 {
|
||||
rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding"));
|
||||
store.changed(&id, &mut server);
|
||||
}
|
||||
assert!(store.flush().is_empty());
|
||||
id
|
||||
};
|
||||
let log_path = log_path.join(&id).join(LOG);
|
||||
let before = fs::read(&log_path).unwrap();
|
||||
// The cap lowered just under the log: it can't be read now, but it must still be there after.
|
||||
let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() };
|
||||
{
|
||||
let mut store = Store::open(dir.path(), lowered).unwrap();
|
||||
let opened = store.open(&id);
|
||||
assert!(opened.is_err(), "the file isn't opened without its log");
|
||||
assert!(!store.problems().is_empty(), "the problem is reported");
|
||||
assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was");
|
||||
}
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let server = store.open(&id).unwrap();
|
||||
assert_eq!(page_name(&server), last, "every confirmed edit comes back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_that_isnt_one_is_moved_aside() {
|
||||
let dir = TempDir::new("badlog");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let folder = dir.path().join(FILES).join(&id);
|
||||
let junk = b"not a log at all, but maybe worth keeping".to_vec();
|
||||
fs::write(folder.join(LOG), &junk).unwrap();
|
||||
drop(store);
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
store.open(&id).unwrap();
|
||||
assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems());
|
||||
let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk));
|
||||
assert!(kept, "the old log's bytes are kept beside the file");
|
||||
}
|
||||
|
||||
const BIDI: [char; 12] =
|
||||
['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}'];
|
||||
|
||||
#[test]
|
||||
fn names_and_report_reasons_lose_bidi_controls() {
|
||||
let dir = TempDir::new("bidi");
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
for (n, m) in BIDI.into_iter().enumerate() {
|
||||
let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap();
|
||||
let (id, _, _) = publish(&mut store, &format!("a{m}b"));
|
||||
assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32);
|
||||
store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap();
|
||||
assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32);
|
||||
store.remove(&id).unwrap();
|
||||
}
|
||||
// A reason that is only control characters says nothing.
|
||||
let (id, _, _) = publish(&mut store, "blank");
|
||||
for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] {
|
||||
assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maxed_out_generation_does_not_wrap() {
|
||||
let dir = TempDir::new("maxgen");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Max");
|
||||
let path = dir.path().join(FILES).join(&id);
|
||||
let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
|
||||
// Loading: no log goes with the snapshot, so the next generation would overflow.
|
||||
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap();
|
||||
fs::remove_file(path.join(LOG)).unwrap();
|
||||
assert!(store.open(&id).is_err());
|
||||
// Compacting: the open log is at the largest generation.
|
||||
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap();
|
||||
let _ = fs::remove_file(path.join(LOG));
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
store.changed(&id, &mut server);
|
||||
for i in 0..4 {
|
||||
rename(&mut server, &mut a, &format!("P{i}"));
|
||||
store.changed(&id, &mut server);
|
||||
store.flush();
|
||||
}
|
||||
let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
|
||||
assert_eq!(snap, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_leftover_temporary_file_is_removed_at_startup() {
|
||||
let dir = TempDir::new("leftovers");
|
||||
let id = {
|
||||
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
let id = publish(&mut store, "A").0;
|
||||
store.flush();
|
||||
id
|
||||
};
|
||||
let folder = dir.path().join(FILES).join(&id);
|
||||
let leftovers = [
|
||||
dir.path().join("blocked.json.tmp"),
|
||||
dir.path().join("reports.json.tmp"),
|
||||
dir.path().join("server.key.tmp"),
|
||||
folder.join("usage.json.tmp"),
|
||||
folder.join("library-1.bin.tmp"),
|
||||
];
|
||||
for path in &leftovers {
|
||||
fs::write(path, b"half").unwrap();
|
||||
}
|
||||
let _store = Store::open(dir.path(), cfg(dir.path())).unwrap();
|
||||
for path in &leftovers {
|
||||
assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! A generation field damaged after an abrupt stop must not make the server drop a log that still
|
||||
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
|
||||
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
|
||||
//! still restores silently.
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg};
|
||||
use figcraft_collab::{Client, Outgoing, Replica, Server};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_server::config::Config;
|
||||
use figcraft_server::disk::{self, LOG, SNAPSHOT};
|
||||
use figcraft_server::store::{FILES, Store};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_name(server: &Server) -> String {
|
||||
let doc = server.document();
|
||||
doc.node(doc.first_page().unwrap()).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then
|
||||
/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state.
|
||||
fn renamed_and_stopped(data: &Path) -> (String, Server) {
|
||||
let mut store = Store::open(data, Config::default().store()).unwrap();
|
||||
let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap();
|
||||
let (info, _, _) = store.publish("Fixture", &snapshot).unwrap();
|
||||
let id = info.id;
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") };
|
||||
let (_, messages) = server.join_as(hello, Access::Edit).unwrap();
|
||||
let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") };
|
||||
let mut client = Client::join(welcome).unwrap();
|
||||
store.changed(&id, &mut server);
|
||||
let mut edited = client.document().clone();
|
||||
let page = edited.first_page().unwrap();
|
||||
edited.node_mut(page).unwrap().name = "Kept".into();
|
||||
let change = client.edit(&edited).unwrap().unwrap();
|
||||
assert!(!server.receive(client.id(), change).is_empty());
|
||||
store.changed(&id, &mut server);
|
||||
assert!(store.flush().is_empty(), "the edit must reach disk");
|
||||
assert_eq!(page_name(&server), "Kept");
|
||||
let dir = data.join(FILES).join(&id);
|
||||
assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty());
|
||||
assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log");
|
||||
drop(store);
|
||||
(id, server)
|
||||
}
|
||||
|
||||
/// Reopen the data directory: the file's first page name and the problems reported.
|
||||
fn reopen(data: &Path, id: &str) -> (String, Vec<String>) {
|
||||
let mut store = Store::open(data, Config::default().store()).unwrap();
|
||||
let server = store.open(id).unwrap();
|
||||
(page_name(&server), store.problems().to_vec())
|
||||
}
|
||||
|
||||
fn flip(path: &Path, at: usize) {
|
||||
let mut bytes = fs::read(path).unwrap();
|
||||
bytes[at] ^= 1;
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// The generation field's first byte in both headers (after the magic and the format byte).
|
||||
const GENERATION_AT: usize = 5;
|
||||
|
||||
#[test]
|
||||
fn an_intact_log_restores_its_edits_without_a_report() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept");
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_log_generation_keeps_the_edits_and_is_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let log = fixture.0.join(FILES).join(&id).join(LOG);
|
||||
flip(&log, GENERATION_AT);
|
||||
// The records themselves still read: only the header's generation changed.
|
||||
let read = disk::read_log(&fs::read(&log).unwrap()).unwrap();
|
||||
assert!(read.damaged.is_none() && !read.entries.is_empty());
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept", "the flushed rename must survive");
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
|
||||
// And it stays: the recovered edit is now in the file's snapshot.
|
||||
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT);
|
||||
flip(&snapshot, GENERATION_AT);
|
||||
assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum");
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept", "the flushed rename must survive");
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
|
||||
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_log_format_is_still_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
flip(&fixture.0.join(FILES).join(&id).join(LOG), 4);
|
||||
let (_, problems) = reopen(&fixture.0, &id);
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, server) = renamed_and_stopped(&fixture.0);
|
||||
// The next snapshot was written (it holds the rename); the old log wasn't replaced yet.
|
||||
let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap();
|
||||
fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap();
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept");
|
||||
assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod header_recovery;
|
||||
mod problem_retention;
|
||||
@@ -24,6 +24,8 @@ log = { workspace = true }
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
base64 = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>FigCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #f5f5f5; }
|
||||
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
|
||||
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
|
||||
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! `window.figcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving FigCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result
|
||||
//! await figcraft.request("document.inspect") // control method → its result
|
||||
//! await figcraft.commands() // command list
|
||||
//! const {base64} = await figcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
|
||||
//! figcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_ui_egui::{ControlReply, ControlRequest};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
type Waiting = (Receiver<ControlReply>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static QUEUE: RefCell<Vec<ControlRequest>> = const { RefCell::new(Vec::new()) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
|
||||
}
|
||||
|
||||
pub fn connect(ctx: egui::Context) {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
/// The page's requests since the last frame, for the app's control queue.
|
||||
pub fn take_requests() -> Vec<ControlRequest> {
|
||||
QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut()))
|
||||
}
|
||||
|
||||
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
|
||||
pub fn capture(name: &str, bytes: &[u8]) -> bool {
|
||||
if !CAPTURE.with(Cell::get) {
|
||||
return false;
|
||||
}
|
||||
SAVED.with(|s| s.borrow_mut().keep(name, bytes));
|
||||
true
|
||||
}
|
||||
|
||||
fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(ControlReply { result: Ok(v), .. }) => {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
|
||||
false
|
||||
}
|
||||
Ok(ControlReply { result: Err(e), .. }) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&e));
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (reply, rx) = std::sync::mpsc::channel();
|
||||
let id = NEXT_ID.with(|n| {
|
||||
n.set(n.get() + 1);
|
||||
n.get()
|
||||
});
|
||||
let req = ControlRequest { id: json!(id), method: method.clone(), params: params.clone(), reply, state: Default::default() };
|
||||
QUEUE.with(|q| q.borrow_mut().push(req));
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
});
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
p
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.figcraft`.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("document.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
|
||||
request("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"files",
|
||||
Closure::<dyn Fn() -> JsValue>::new(|| {
|
||||
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
|
||||
SAVED.with(|s| {
|
||||
s.borrow()
|
||||
.get(&name)
|
||||
.map(|b| js_sys::Uint8Array::from(b).into())
|
||||
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"figcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -13,8 +13,22 @@
|
||||
//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
|
||||
//! join from other machines.
|
||||
//!
|
||||
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
|
||||
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
|
||||
//!
|
||||
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
|
||||
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
|
||||
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
|
||||
//! bar and the browser history keep it, so the URL is as private as the link itself.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod query;
|
||||
#[cfg(test)]
|
||||
mod query_tests;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! The page's URL: the invite link its fragment names, and the flags its query sets.
|
||||
|
||||
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
|
||||
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
|
||||
/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted
|
||||
/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite
|
||||
/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is
|
||||
/// opened with.
|
||||
pub fn join_link(fragment: &str) -> Option<&str> {
|
||||
// An invite link is at most 2 KiB; every byte of it escaped is three.
|
||||
const MAX: usize = 3 * 2048;
|
||||
let fragment = fragment.strip_prefix('#').unwrap_or(fragment);
|
||||
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
|
||||
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
|
||||
}
|
||||
|
||||
/// Whether `search` (`location.search`, with or without its `?`) has a `control` entry (`?control`,
|
||||
/// or `?control=…` with any value): the page's control channel, `window.figcraft`, is installed only
|
||||
/// when it is asked for.
|
||||
pub fn control(search: &str) -> bool {
|
||||
search.strip_prefix('?').unwrap_or(search).split('&').any(|entry| entry.split('=').next() == Some("control"))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! `#join=` and `?control` in the page's URL.
|
||||
|
||||
use crate::query::{control, join_link};
|
||||
|
||||
#[test]
|
||||
fn join_takes_the_rest_of_the_fragment() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s");
|
||||
assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries");
|
||||
assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later");
|
||||
assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_join_without_the_entry() {
|
||||
assert_eq!(join_link(""), None);
|
||||
assert_eq!(join_link("#"), None);
|
||||
assert_eq!(join_link("#join="), None);
|
||||
assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name");
|
||||
assert_eq!(join_link("#xjoin=x&a=1"), None);
|
||||
assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_link_is_ignored() {
|
||||
let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000));
|
||||
assert_eq!(join_link(&long), None);
|
||||
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_is_opt_in() {
|
||||
assert!(control("?control"));
|
||||
assert!(control("?webgl&control=1"), "after other entries, with a value");
|
||||
assert!(control("control"), "no leading ?");
|
||||
assert!(!control(""));
|
||||
assert!(!control("?"));
|
||||
assert!(!control("?webgl"));
|
||||
assert!(!control("?nocontrol&controls"), "only the whole entry name");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
|
||||
|
||||
/// Saved files kept (the oldest go first).
|
||||
pub const MAX_SAVED: usize = 16;
|
||||
|
||||
/// The last component of a `/` or `\` path.
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Files by name, newest last: saving a name again replaces it.
|
||||
#[derive(Default)]
|
||||
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
|
||||
|
||||
impl SavedFiles {
|
||||
/// Keep `bytes` under the last component of `path`.
|
||||
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
|
||||
let name = file_name(path).to_string();
|
||||
self.0.retain(|(n, _)| *n != name);
|
||||
self.0.push((name, bytes.to_vec()));
|
||||
let extra = self.0.len().saturating_sub(MAX_SAVED);
|
||||
self.0.drain(..extra);
|
||||
}
|
||||
|
||||
/// A kept file by its name, or by a path ending in it.
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
let wanted = file_name(name);
|
||||
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
|
||||
}
|
||||
|
||||
/// `(name, size)` of each kept file, oldest first.
|
||||
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
|
||||
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_by_name_replaces_and_reads_back_by_path() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("exports/poster.png", b"one");
|
||||
s.keep(r"C:\out\poster.png", b"two");
|
||||
s.keep("notes.txt", b"three");
|
||||
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
|
||||
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
|
||||
assert_eq!(s.get("missing.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_the_oldest_past_the_cap() {
|
||||
let mut s = SavedFiles::default();
|
||||
for i in 0..MAX_SAVED + 3 {
|
||||
s.keep(&format!("f{i}.png"), &[0]);
|
||||
}
|
||||
assert_eq!(s.list().count(), MAX_SAVED);
|
||||
assert_eq!(s.get("f0.png"), None);
|
||||
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_separator_keeps_the_whole_name() {
|
||||
let mut s = SavedFiles::default();
|
||||
s.keep("odd/", b"x");
|
||||
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services` and the eframe web runner.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const CANVAS_ID: &str = "figcraft_canvas";
|
||||
@@ -40,7 +40,20 @@ pub fn start() {
|
||||
// on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
|
||||
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
|
||||
if let Some(link) = crate::query::join_link(&hash) {
|
||||
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, &link);
|
||||
}
|
||||
// `?control`: `window.figcraft`, the page's control channel (crate::api), opt-in.
|
||||
if crate::query::control(&query()) {
|
||||
crate::api::connect(cc.egui_ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("couldn't install window.figcraft: {e:?}");
|
||||
}
|
||||
}
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
}),
|
||||
)
|
||||
@@ -63,15 +76,26 @@ struct WebShell {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
// `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app.
|
||||
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
|
||||
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
|
||||
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
|
||||
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
|
||||
/// not repeat it.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.fig.control_queue.extend(crate::api::take_requests());
|
||||
self.fig.ui(ui);
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
|
||||
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
|
||||
/// events with their text (a page can't read the clipboard when it likes).
|
||||
/// Browser services. Files are opened and imported through the browser's file picker, which hands
|
||||
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
|
||||
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
|
||||
/// paste events with their text (a page can't read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
@@ -79,11 +103,24 @@ struct WebServices {
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
|
||||
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
Some(Box::pin(async move {
|
||||
pick_file(&accept).await.unwrap_or_else(|e| {
|
||||
log::error!("couldn't open a file: {e:?}");
|
||||
None
|
||||
})
|
||||
}))
|
||||
}
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed.
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
if crate::api::capture(name, bytes) {
|
||||
return true;
|
||||
}
|
||||
match download(name, bytes) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
@@ -100,7 +137,7 @@ impl Services for WebServices {
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
|
||||
if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") {
|
||||
log::error!("couldn't open {url}: {e:?}");
|
||||
}
|
||||
}
|
||||
@@ -130,6 +167,90 @@ impl Services for WebServices {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
|
||||
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
|
||||
/// can't be read.
|
||||
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
input.set_hidden(true);
|
||||
// Some browsers (older Safari) open the picker only for an input in the page.
|
||||
body.append_child(&input)?;
|
||||
let (closed, picker) = PickerClosed::new(&window, input.clone());
|
||||
input.click();
|
||||
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
|
||||
let file = input.files().and_then(|files| files.get(0));
|
||||
drop(picker);
|
||||
result?;
|
||||
let Some(file) = file else { return Ok(None) };
|
||||
let name = file.name();
|
||||
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
|
||||
return Ok(Some(Err(why)));
|
||||
}
|
||||
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
|
||||
Err(e) => {
|
||||
log::error!("couldn't read {name}: {e:?}");
|
||||
Err(figcraft_ui_egui::unreadable(&name))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
|
||||
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
|
||||
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
|
||||
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
|
||||
|
||||
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
|
||||
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
|
||||
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
|
||||
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
|
||||
struct PickerClosed {
|
||||
window: web_sys::Window,
|
||||
input: web_sys::HtmlInputElement,
|
||||
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
|
||||
}
|
||||
|
||||
impl PickerClosed {
|
||||
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
|
||||
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
|
||||
let slot = resolve.clone();
|
||||
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
|
||||
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if let Some(res) = resolve.borrow_mut().take() {
|
||||
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
|
||||
}
|
||||
});
|
||||
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
|
||||
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
|
||||
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
|
||||
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
|
||||
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if t.get().is_none() {
|
||||
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
|
||||
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PickerClosed {
|
||||
fn drop(&mut self) {
|
||||
if let Some(t) = self.timer.take() {
|
||||
self.window.clear_timeout_with_handle(t);
|
||||
}
|
||||
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
|
||||
self.input.remove();
|
||||
}
|
||||
}
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
|
||||
@@ -16,12 +16,16 @@ figcraft-ui-egui = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the
|
||||
# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged
|
||||
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
|
||||
craft-window = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
|
||||
webbrowser = { workspace = true }
|
||||
image = { workspace = true }
|
||||
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
|
||||
@@ -29,8 +33,15 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
|
||||
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
|
||||
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
|
||||
|
||||
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
|
||||
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
|
||||
# accesskit already; this only makes it direct.
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
|
||||
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
|
||||
@@ -43,3 +54,8 @@ wat = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor
|
||||
# probe that used to need raw-window-handle and wayland-client here now lives in craft-window.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
|
||||
//! (no-op elsewhere).
|
||||
fn main() {
|
||||
println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)");
|
||||
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os == "windows" {
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
|
||||
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
|
||||
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
|
||||
for p in paths {
|
||||
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
|
||||
if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() {
|
||||
fig.ui.home = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
|
||||
//! The command line: `figcraft [--control <port>] [--automation-read-root <dir>]…
|
||||
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
|
||||
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
|
||||
//! the app running unconfined. After a bare `--`, every argument is a file.
|
||||
|
||||
/// What the app was started with.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Args {
|
||||
/// `--control <port>`: the JSON-lines control server.
|
||||
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
|
||||
pub control: Option<u16>,
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
pub join: Option<String>,
|
||||
/// `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable; `=` works
|
||||
/// too): the folders the app's file access is confined to. A flag without a value gives `""`,
|
||||
/// which is refused at startup.
|
||||
pub read_roots: Vec<String>,
|
||||
pub write_roots: Vec<String>,
|
||||
/// `--flags` the app doesn't know, as given.
|
||||
pub unknown: Vec<String>,
|
||||
}
|
||||
|
||||
/// An invite link rather than a file (`figcraft:` links, in any case).
|
||||
@@ -49,13 +59,27 @@ pub fn parse(args: &[String]) -> Args {
|
||||
let mut out = Args::default();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
if a == "--" {
|
||||
out.files.extend(it.by_ref().cloned());
|
||||
break;
|
||||
}
|
||||
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => Some(&mut out.read_roots),
|
||||
"--automation-write-root" => Some(&mut out.write_roots),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(roots) = roots {
|
||||
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
|
||||
} else if flag == "--control" {
|
||||
out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok());
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
out.files.push(path);
|
||||
} else if !a.starts_with("--") {
|
||||
} else if a.starts_with("--") {
|
||||
out.unknown.push(a.clone());
|
||||
} else {
|
||||
out.files.push(a.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,50 @@
|
||||
use crate::args::{Args, file_url_path, is_invite, parse};
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
a.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_port_accepts_the_equals_form() {
|
||||
let a = parse(&args(&["--control=7980", "a.figcraft"]));
|
||||
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
|
||||
// A bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_repeat_and_are_never_files() {
|
||||
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
|
||||
assert_eq!(a.read_roots, args(&["in", "more"]));
|
||||
assert_eq!(a.write_roots, args(&["out"]));
|
||||
assert_eq!(a.files, args(&["a.figcraft"]));
|
||||
// A flag without its folder is kept as an empty one, which startup refuses.
|
||||
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_refused_never_ignored() {
|
||||
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
|
||||
// unconfined. Both forms, with and without `=`.
|
||||
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
|
||||
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
|
||||
assert!(a.write_roots.is_empty());
|
||||
// Single-dash arguments are left as they were (files); after `--` everything is a file.
|
||||
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_links_join_instead_of_opening() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_ui_egui::ControlRequest;
|
||||
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
|
||||
@@ -16,6 +16,38 @@ const MAX_LINE: usize = 32 * 1024 * 1024;
|
||||
const MAX_CONNECTIONS: usize = 8;
|
||||
const MAX_QUEUED_REQUESTS: usize = 8;
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Why a request's transport wait ended without a control reply.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum WaitFailure {
|
||||
Timeout,
|
||||
/// The UI side dropped its sender before replying (the app is closing).
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
|
||||
/// A commit that got the gate first has already queued its reply: consume that reply instead of
|
||||
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
|
||||
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(reply) => Ok(reply),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
|
||||
}
|
||||
}
|
||||
|
||||
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
|
||||
/// against a gate whose commit already happened.
|
||||
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
|
||||
match gate.cancel_if_open() {
|
||||
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
|
||||
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
|
||||
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
@@ -38,7 +70,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
|
||||
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
|
||||
let mut workers = Vec::new();
|
||||
for stream in listener.incoming().flatten() {
|
||||
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
|
||||
}
|
||||
});
|
||||
@@ -47,6 +79,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
|
||||
workers.retain(|worker| !worker.is_finished());
|
||||
if workers.len() >= MAX_CONNECTIONS {
|
||||
@@ -60,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
|
||||
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
|
||||
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
|
||||
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
|
||||
out.set_read_timeout(Some(timeout))?;
|
||||
out.set_write_timeout(Some(timeout))?;
|
||||
@@ -87,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let gate = RequestGate::new();
|
||||
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pending.retain(|request| !request.is_cancelled());
|
||||
if pending.len() >= MAX_QUEUED_REQUESTS {
|
||||
@@ -95,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
|
||||
continue;
|
||||
}
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
|
||||
drop(pending);
|
||||
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
match rx.recv_timeout(timeout) {
|
||||
match wait_for_reply(&rx, &gate, timeout) {
|
||||
Ok(r) => match r.result {
|
||||
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
|
||||
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
|
||||
},
|
||||
Err(_) => {
|
||||
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
Err(WaitFailure::Timeout) => {
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
|
||||
}
|
||||
Err(WaitFailure::Disconnected) => {
|
||||
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
/// Build a `ui.inspect` control request with a throwaway reply channel.
|
||||
fn request() -> ControlRequest {
|
||||
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
|
||||
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
|
||||
/// timeout branch consumes that reply and leaves the state `Replied`.
|
||||
#[test]
|
||||
fn a_committed_reply_wins_over_the_transport_timeout() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
|
||||
let path = dir.join("shot.png");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let gate = RequestGate::new();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
// The UI's final commit gets the gate first: the file and reply are both committed before the
|
||||
// transport's timeout branch can look at the gate.
|
||||
let committed = gate.commit(|| {
|
||||
std::fs::write(&path, b"png").unwrap();
|
||||
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
|
||||
});
|
||||
assert!(committed.is_some());
|
||||
assert!(path.is_file());
|
||||
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
|
||||
assert_eq!(reply.id, json!(7));
|
||||
assert!(reply.result.is_ok());
|
||||
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
|
||||
assert!(path.is_file(), "a winning commit keeps its file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
|
||||
#[test]
|
||||
fn a_timeout_cancels_an_open_request() {
|
||||
let gate = RequestGate::new();
|
||||
let (_tx, rx) = mpsc::channel::<ControlReply>();
|
||||
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
|
||||
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
|
||||
}
|
||||
|
||||
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
|
||||
/// cancelled.
|
||||
#[test]
|
||||
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
|
||||
let gate = RequestGate::new();
|
||||
let (tx, rx) = mpsc::channel::<ControlReply>();
|
||||
drop(tx);
|
||||
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
|
||||
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_partial_frame_connections_expire() {
|
||||
for bytes in [b"".as_slice(), b"{\"id\":"] {
|
||||
@@ -131,6 +178,18 @@ fn idle_and_partial_frame_connections_expire() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps failing
|
||||
/// does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
let pending = queue();
|
||||
@@ -160,7 +219,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
assert_eq!(workers.len(), 1);
|
||||
client.write_all(b"invalid\n").unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
// The server closes this connection after answering an invalid request, and shutting down
|
||||
// a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux
|
||||
// returns Ok. Closing the client is what the worker is waiting for, so the result is moot:
|
||||
// the other shutdowns in this file run while the peer is still open and stay checked.
|
||||
let _ = client.shutdown(Shutdown::Both);
|
||||
workers.pop().unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
@@ -211,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
|
||||
/// and answered, and a window that finally draws discards its expired command before
|
||||
/// dispatch.
|
||||
#[test]
|
||||
fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
|
||||
@@ -246,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
// Include expired requests in the socket queue, too: admission must reclaim their slots.
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
|
||||
let r = request();
|
||||
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
r.state.cancel_if_open();
|
||||
r
|
||||
}));
|
||||
let (mut client, server) = pair();
|
||||
|
||||
+197
-31
@@ -6,9 +6,20 @@
|
||||
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
|
||||
//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
|
||||
//!
|
||||
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
|
||||
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
|
||||
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
|
||||
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
|
||||
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit
|
||||
// reports a removed UI Automation node. Catch build configurations that drop the workaround.
|
||||
#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))]
|
||||
compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod args;
|
||||
@@ -56,6 +67,9 @@ struct App {
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
/// Native eframe logic callback: store the first context, drain control requests, run
|
||||
/// guarded background work, poll macOS open events, handle close requests and title
|
||||
/// changes, and schedule recovery repaints on the app clock.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
{
|
||||
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -65,7 +79,9 @@ impl eframe::App for App {
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
figcraft_ui_egui::control::drain_hidden(fig, ctx);
|
||||
// Phase-safe background work: control queues, autosave/recovery deadlines, live polling.
|
||||
// It runs on logic-only callbacks too, so it never reads UI input.
|
||||
figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.apple_events, fig, ctx);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
@@ -80,8 +96,14 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.title = title;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
|
||||
// Recovery deadlines use the same monotonic clock as the app's background work: a
|
||||
// logic-only tick leaves `ctx.input(i.time)` stale.
|
||||
if let Some(wait) = with_app(|fig| {
|
||||
let now = fig.now_secs();
|
||||
recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))
|
||||
})
|
||||
.flatten()
|
||||
{
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
}
|
||||
}
|
||||
@@ -100,14 +122,22 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
with_app(|fig| services::save_state(fig));
|
||||
if let Some(r) = recovery::lock(&self.recovery).take() {
|
||||
r.finish();
|
||||
}
|
||||
// With the app out of reach (busy), its recovery copies stay for the next start.
|
||||
let copies = recovery::lock(&self.recovery).take();
|
||||
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
|
||||
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
|
||||
// throws on the content view AccessKit restores (craft-libs#3, #781).
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
@@ -119,35 +149,56 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
let args = args::parse(&args);
|
||||
if let Some(flag) = args.unknown.first() {
|
||||
eprintln!(
|
||||
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let roots = match (
|
||||
args.read_roots.is_empty() && args.write_roots.is_empty(),
|
||||
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
|
||||
) {
|
||||
(true, _) => None,
|
||||
(false, Ok(r)) => Some(Arc::new(r)),
|
||||
(false, Err(e)) => {
|
||||
eprintln!("FigCraft: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
|
||||
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
|
||||
// to lay text out again with them.
|
||||
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
|
||||
let fonts_slot = ctx_slot.clone();
|
||||
// Wakes the window (once it is there) from another thread.
|
||||
let wake = |slot: &Arc<Mutex<Option<egui::Context>>>| {
|
||||
let slot = slot.clone();
|
||||
move || {
|
||||
if let Some(c) = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
}
|
||||
};
|
||||
let fonts_wake = wake(&ctx_slot);
|
||||
std::thread::spawn(move || {
|
||||
figcraft_text::scan_system_fonts();
|
||||
if let Some(c) = fonts_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
fonts_wake();
|
||||
});
|
||||
let mut session = Session::empty();
|
||||
// Live sessions: the network threads wake the window when something arrives.
|
||||
let wake_slot = ctx_slot.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || {
|
||||
if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
}))));
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(wake(&ctx_slot)))));
|
||||
for f in &args.files {
|
||||
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
let opened: Vec<u64> = session.docs.iter().map(|d| d.uid).collect();
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
let recovery = recovery::Shared::default();
|
||||
let svc = services::Desktop::load(recovery.clone());
|
||||
svc.follow_system_dark(wake(&ctx_slot));
|
||||
if let Some(prefs) = svc.prefs.clone() {
|
||||
session.prefs = prefs;
|
||||
}
|
||||
@@ -164,17 +215,19 @@ fn main() -> eframe::Result {
|
||||
// default, never a build from source unless asked).
|
||||
session.live.links = figcraft_collab_net::links::system();
|
||||
session.invite_links_startup();
|
||||
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
|
||||
// tips and templates; later runs start in a new file.
|
||||
let first_run = svc.prefs.is_none() && args.join.is_none();
|
||||
if session.docs.is_empty() && !first_run {
|
||||
if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
// The interface language from the first frame on, and for the message when the window can't
|
||||
// open (no frame then).
|
||||
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.set_startup_update_check(true);
|
||||
fig.automation_roots = roots;
|
||||
fig.ui.home = false;
|
||||
// Files named on the command line are the user's too: their missing fonts are listed.
|
||||
fig.ui.font_checks = opened;
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
@@ -192,15 +245,15 @@ fn main() -> eframe::Result {
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
|
||||
// window is frameless and FigCraft draws the buttons and resize edges.
|
||||
if cfg!(target_os = "macos") {
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset;
|
||||
} else {
|
||||
viewport = viewport.with_decorations(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom;
|
||||
}
|
||||
// Who draws the window frame is decided once, before the viewport is built, by the shared
|
||||
// craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is
|
||||
// kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the
|
||||
// decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway,
|
||||
// Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter —
|
||||
// which draws no server-side frame — leaves it to the app's own custom title bar. This replaces
|
||||
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
|
||||
fig.ui.title_bar = desktop_title_bar();
|
||||
viewport = window_chrome(viewport, fig.ui.title_bar);
|
||||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
@@ -216,14 +269,15 @@ fn main() -> eframe::Result {
|
||||
let result = eframe::run_native(
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
Box::new(move |cc| {
|
||||
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
title: String::new(),
|
||||
recovery,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&_cc.egui_ctx),
|
||||
apple_events: apple_events.connect(&cc.egui_ctx),
|
||||
}))
|
||||
}),
|
||||
);
|
||||
@@ -233,3 +287,115 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// With nothing to open, whether the window starts in a new file rather than on the home screen.
|
||||
/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs
|
||||
/// start in a new file unless the preference asks for Home, which `--control` overrides: its
|
||||
/// callers expect a document to act on.
|
||||
fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool {
|
||||
!first_run && (!start_on_home || control)
|
||||
}
|
||||
|
||||
/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app
|
||||
/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR`
|
||||
/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the
|
||||
/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager
|
||||
/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one).
|
||||
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
|
||||
let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT"));
|
||||
title_bar_for(choice.into_frame())
|
||||
}
|
||||
|
||||
/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`].
|
||||
///
|
||||
/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop`
|
||||
/// means the desktop draws it, but FigCraft keeps each platform's existing look there:
|
||||
/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged.
|
||||
/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used
|
||||
/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that.
|
||||
/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only
|
||||
/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame.
|
||||
fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
match frame {
|
||||
craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset,
|
||||
// Windows kept FigCraft's own title bar before this change; keep it.
|
||||
craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom,
|
||||
craft_window::Frame::Desktop => TitleBar::Native,
|
||||
// The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override).
|
||||
craft_window::Frame::App(_) => TitleBar::Custom,
|
||||
}
|
||||
}
|
||||
|
||||
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
match title_bar {
|
||||
TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
|
||||
TitleBar::Native => viewport.with_decorations(true),
|
||||
TitleBar::Custom => viewport.with_decorations(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
|
||||
/// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override)
|
||||
/// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the
|
||||
/// decorations so FigCraft draws its own.
|
||||
#[test]
|
||||
fn the_app_frame_maps_to_the_custom_title_bar() {
|
||||
let layout = craft_window::Layout::default();
|
||||
let bar = super::title_bar_for(craft_window::Frame::App(layout));
|
||||
assert_eq!(bar, TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
}
|
||||
|
||||
/// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic
|
||||
/// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's
|
||||
/// native frame. The Linux case is the only behaviour change: KDE and the other
|
||||
/// server-side-decoration compositors now keep their native frame.
|
||||
#[test]
|
||||
fn the_desktop_frame_is_mapped_per_platform() {
|
||||
let bar = super::title_bar_for(craft_window::Frame::Desktop);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(bar, TitleBar::Inset);
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// Windows kept its own title bar before and must keep it now.
|
||||
assert_eq!(bar, TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
} else {
|
||||
assert_eq!(bar, TitleBar::Native);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS is untouched by this change: its inset title bar with the system traffic lights stays,
|
||||
/// and `window_chrome` still asks for the full-size content view rather than toggling the
|
||||
/// window decorations.
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_keeps_its_inset_title_bar() {
|
||||
let bar = super::desktop_title_bar();
|
||||
assert_eq!(bar, TitleBar::Inset);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.fullsize_content_view, Some(true));
|
||||
assert_eq!(viewport.title_shown, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod start_tests {
|
||||
use super::opens_new_file;
|
||||
|
||||
#[test]
|
||||
fn the_home_preference_starts_on_home_except_for_control_callers() {
|
||||
assert!(opens_new_file(false, false, false), "a new file by default");
|
||||
assert!(!opens_new_file(false, true, false), "Home when the preference asks");
|
||||
assert!(opens_new_file(false, true, true), "--control still starts with a document");
|
||||
assert!(!opens_new_file(true, false, false), "a first run shows the home screen");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +120,7 @@ impl Recovery {
|
||||
(0..10).find_map(|n| {
|
||||
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
let lock = lock_folder(&dir).ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
})
|
||||
}
|
||||
@@ -207,10 +206,12 @@ impl Recovery {
|
||||
let doc = d.doc.clone();
|
||||
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
|
||||
// Which online file it is and what of the server's this very copy has (a line each:
|
||||
// the server, the file's id, then the session, client id and last change), while it
|
||||
// the server, the file's id, then the session, client id, last change and the secret the
|
||||
// client id resumes with), while it
|
||||
// is one. Written after the copy, never before: a note must not promise more of the
|
||||
// server's changes than its copy holds.
|
||||
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
|
||||
let online =
|
||||
s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret));
|
||||
let job = move || {
|
||||
if !write_atomic(&path, &doc, &versions, &branches) {
|
||||
return false;
|
||||
@@ -250,6 +251,17 @@ impl Recovery {
|
||||
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
|
||||
}
|
||||
|
||||
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
|
||||
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
|
||||
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
|
||||
pub fn quit(mut self, s: &Session, asked: bool) {
|
||||
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
|
||||
return self.finish();
|
||||
}
|
||||
self.write(s, true);
|
||||
self.settle(s);
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
@@ -282,6 +294,17 @@ fn remove_kept(w: Waiting) {
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// How long the startup waits, once, for folder locks that may free themselves (#334).
|
||||
#[cfg(unix)]
|
||||
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
|
||||
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
|
||||
lock.try_lock()?;
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
|
||||
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
|
||||
/// file isn't recorded any more, or is open already): then it opens as any other copy.
|
||||
@@ -291,7 +314,10 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
|
||||
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
|
||||
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
|
||||
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
|
||||
// Absent in notes written before ids had secrets.
|
||||
let secret = at.next().flatten().unwrap_or(0);
|
||||
let params =
|
||||
json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq, "secret": secret});
|
||||
s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64()
|
||||
}
|
||||
|
||||
@@ -317,7 +343,24 @@ fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
let Ok(mut f) = opts.open(&tmp) else { return false };
|
||||
if std::io::Write::write_all(&mut f, &bytes).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if std::fs::write(&tmp, bytes).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
|
||||
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
|
||||
@@ -351,6 +394,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
}
|
||||
}
|
||||
};
|
||||
let (mut free, mut held) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if !path.is_dir() {
|
||||
@@ -358,10 +402,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
continue;
|
||||
}
|
||||
// Still running (another window of the app), or not ours to take.
|
||||
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
match lock_folder(&path) {
|
||||
Ok(lock) => free.push((path, lock)),
|
||||
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
|
||||
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
|
||||
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
|
||||
// lock to child processes.
|
||||
#[cfg(unix)]
|
||||
if !held.is_empty() {
|
||||
std::thread::sleep(LOCK_WAIT);
|
||||
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
|
||||
}
|
||||
for (path, lock) in free {
|
||||
let lock = Arc::new(lock);
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
|
||||
for f in &files {
|
||||
|
||||
@@ -193,7 +193,7 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
|
||||
let left = crash(&s, &root);
|
||||
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
|
||||
let at = s.online_of(s.docs[0].uid).unwrap().2;
|
||||
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
|
||||
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret), "which file, who the app was, what it had");
|
||||
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
|
||||
let (mut next, script, mut r, opened) = restart(&s, &root);
|
||||
assert_eq!(opened, 0, "nothing comes back as a plain copy");
|
||||
|
||||
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
|
||||
let root = root("quitunasked");
|
||||
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
|
||||
let s = edited();
|
||||
Recovery::start(&root).unwrap().quit(&s, false);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
|
||||
for (s, asked) in [(edited(), true), (Session::new(), false)] {
|
||||
Recovery::start(&root).unwrap().quit(&s, asked);
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let root = root("pidreuse");
|
||||
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
|
||||
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
|
||||
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
|
||||
let root = root("fork-window");
|
||||
crashed(&edited(), &root);
|
||||
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
|
||||
// exec holds it, and free it shortly after.
|
||||
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let freed = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
drop(lock);
|
||||
});
|
||||
// The next start waits for that lock rather than reading the run as still going.
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
|
||||
freed.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The rectangles in `doc`.
|
||||
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
|
||||
|
||||
+231
-20
@@ -2,8 +2,8 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
|
||||
@@ -11,15 +11,20 @@ use serde_json::{Value, json};
|
||||
|
||||
pub struct Desktop {
|
||||
clipboard: Option<arboard::Clipboard>,
|
||||
egui_ctx: Option<egui::Context>,
|
||||
/// Where the preferences live ([`config_dir`]).
|
||||
dir: Option<PathBuf>,
|
||||
pub prefs: Option<Prefs>,
|
||||
/// The system's reduce-motion setting (read in the background at startup).
|
||||
reduce_motion: Arc<AtomicBool>,
|
||||
/// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light.
|
||||
dark: Arc<AtomicU8>,
|
||||
/// The system's interface language (read at startup).
|
||||
language: Option<String>,
|
||||
/// Recovery copies of unsaved files (written at once after a panic).
|
||||
recovery: crate::recovery::Shared,
|
||||
/// The folder of the last file or folder a dialog picked, where the next one starts.
|
||||
last_folder: Arc<Mutex<Option<PathBuf>>>,
|
||||
}
|
||||
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
@@ -54,7 +59,33 @@ impl Desktop {
|
||||
let reduce_motion = Arc::new(AtomicBool::new(false));
|
||||
let flag = reduce_motion.clone();
|
||||
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
|
||||
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
|
||||
let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN));
|
||||
Self {
|
||||
clipboard: arboard::Clipboard::new().ok(),
|
||||
egui_ctx: None,
|
||||
dir,
|
||||
prefs,
|
||||
reduce_motion,
|
||||
dark,
|
||||
language: system_language(),
|
||||
recovery,
|
||||
last_folder: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// winit reports no system theme on Linux and the BSDs: there the freedesktop colour scheme
|
||||
/// answers instead, read once and then watched in the background, calling `wake` when it
|
||||
/// changes (an idle window draws no frames). Elsewhere this does nothing.
|
||||
pub fn follow_system_dark(&self, wake: impl Fn() + Send + 'static) {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if let Err(e) = std::thread::Builder::new().name("figcraft-theme".into()).spawn({
|
||||
let dark = self.dark.clone();
|
||||
move || watch_system_dark(&dark, &wake)
|
||||
}) {
|
||||
log::info!("colour scheme: can't start the watcher: {e}");
|
||||
}
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let _ = wake;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +167,71 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
||||
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
||||
}
|
||||
|
||||
/// [`Desktop::dark`]: the colour scheme is unknown, dark, or light.
|
||||
const DARK_UNKNOWN: u8 = 0;
|
||||
const DARK_DARK: u8 = 1;
|
||||
const DARK_LIGHT: u8 = 2;
|
||||
|
||||
/// The desktop's colour scheme through the freedesktop settings portal
|
||||
/// (`org.freedesktop.appearance` / `color-scheme`: 1 dark, 2 light, 0 no preference, which is
|
||||
/// light), read once and then watched for changes, each one calling `wake`. Without a session bus
|
||||
/// or a portal the scheme stays unknown and the UI keeps its light fallback.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn watch_system_dark(dark: &AtomicU8, wake: &dyn Fn()) {
|
||||
use zbus::zvariant::Value;
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
const APPEARANCE: &str = "org.freedesktop.appearance";
|
||||
// `Read` (portals before 1.17, which lack `ReadOne`) wraps the value in one more variant.
|
||||
let scheme = |v: &Value| match v {
|
||||
Value::U32(x) => *x,
|
||||
Value::Value(inner) => match **inner {
|
||||
Value::U32(x) => x,
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
};
|
||||
let store = |v: u32| {
|
||||
let new = match v {
|
||||
1 => DARK_DARK,
|
||||
2 => DARK_LIGHT,
|
||||
_ => DARK_UNKNOWN,
|
||||
};
|
||||
if dark.swap(new, Ordering::Relaxed) != new {
|
||||
wake();
|
||||
}
|
||||
};
|
||||
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.build()) else {
|
||||
log::info!("colour scheme: no session bus: the system theme isn't followed");
|
||||
return;
|
||||
};
|
||||
let read = |method: &str| -> zbus::Result<u32> {
|
||||
let reply = conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), method, &(APPEARANCE, "color-scheme"))?;
|
||||
let body = reply.body();
|
||||
Ok(scheme(&body.deserialize::<Value>()?))
|
||||
};
|
||||
match read("ReadOne").or_else(|_| read("Read")) {
|
||||
Ok(v) => store(v),
|
||||
Err(e) => log::info!("colour scheme: can't read the system setting: {e}"),
|
||||
}
|
||||
let watch = || -> zbus::Result<()> {
|
||||
let rule =
|
||||
zbus::MatchRule::builder().msg_type(zbus::message::Type::Signal).sender(PORTAL)?.interface(SETTINGS)?.member("SettingChanged")?.build();
|
||||
for msg in zbus::blocking::MessageIterator::for_match_rule(rule, &conn, Some(8))? {
|
||||
let msg = msg?;
|
||||
let body = msg.body();
|
||||
let Ok((namespace, key, value)) = body.deserialize::<(String, String, Value<'_>)>() else { continue };
|
||||
if namespace == APPEARANCE && key == "color-scheme" {
|
||||
store(scheme(&value));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = watch() {
|
||||
log::info!("colour scheme: can't watch the system setting: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
|
||||
fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -167,10 +263,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
|
||||
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
|
||||
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
|
||||
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
|
||||
if let Some(dir) = dir {
|
||||
write_prefs(dir, &app.session.prefs);
|
||||
}
|
||||
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
|
||||
if let Some(r) = recovery {
|
||||
r.quit(&app.session, app.ui.quit);
|
||||
}
|
||||
drop(std::mem::take(&mut app.session.live));
|
||||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
@@ -223,23 +327,102 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
|
||||
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).add_filter("Supported files", exts).pick_file())
|
||||
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle
|
||||
/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no
|
||||
/// timeout of its own, so an app that hangs must not hang the window too.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read_clipboard<T: Send + 'static>(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c)));
|
||||
});
|
||||
rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten()
|
||||
}
|
||||
|
||||
/// Read the clipboard (the system's own reads don't wait on the app that copied).
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn read_clipboard<T>(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
|
||||
read(c)
|
||||
}
|
||||
|
||||
/// Native Save as filters; other callers keep their existing combined filter.
|
||||
fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
|
||||
if exts == ["figcraft", "fig"] {
|
||||
vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])]
|
||||
} else {
|
||||
vec![("File", exts)]
|
||||
}
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
|
||||
}
|
||||
fn pick_folder(&mut self, title: &str) -> DialogFuture {
|
||||
picked(dialog(title).pick_folder())
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
let _ = c.set_text(s.to_string());
|
||||
}
|
||||
|
||||
/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder
|
||||
/// picked itself.
|
||||
pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option<PathBuf> {
|
||||
let folder = if is_folder { Some(path) } else { path.parent() };
|
||||
folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// The remembered folder, unless it is gone since: then the system's own choice.
|
||||
pub(crate) fn start_folder(last: Option<PathBuf>) -> Option<PathBuf> {
|
||||
last.filter(|f| f.is_dir())
|
||||
}
|
||||
|
||||
impl Desktop {
|
||||
/// A dialog that starts in the last folder used. The Linux portal does not remember it between
|
||||
/// dialogs as macOS and Windows do.
|
||||
fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog {
|
||||
let dialog = dialog(title);
|
||||
match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) {
|
||||
Some(folder) => dialog.set_directory(folder),
|
||||
None => dialog,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `pick`, with the folder of what it picks kept in `last` for the next dialog.
|
||||
pub(crate) fn remembering(last: Arc<Mutex<Option<PathBuf>>>, pick: DialogFuture, is_folder: bool) -> DialogFuture {
|
||||
Box::pin(async move {
|
||||
let path = pick.await?;
|
||||
if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) {
|
||||
*last = Some(folder);
|
||||
}
|
||||
Some(path)
|
||||
})
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
|
||||
let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file());
|
||||
remembering(self.last_folder.clone(), pick, false)
|
||||
}
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
|
||||
let mut dialog = self.dialog_in_last_folder(title).set_file_name(name);
|
||||
for (label, extensions) in save_filters(exts) {
|
||||
dialog = dialog.add_filter(label, extensions);
|
||||
}
|
||||
let pick = picked(dialog.save_file());
|
||||
remembering(self.last_folder.clone(), pick, false)
|
||||
}
|
||||
fn pick_folder(&mut self, title: &str) -> DialogFuture {
|
||||
let pick = picked(self.dialog_in_last_folder(title).pick_folder());
|
||||
remembering(self.last_folder.clone(), pick, true)
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
if let Some(ctx) = &self.egui_ctx {
|
||||
ctx.copy_text(s.to_string());
|
||||
}
|
||||
}
|
||||
fn set_egui_context(&mut self, ctx: &egui::Context) {
|
||||
self.egui_ctx = Some(ctx.clone());
|
||||
}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.clipboard.as_mut()?.get_text().ok()
|
||||
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
|
||||
}
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
read_clipboard(self.clipboard.as_mut()?, |c| c.get().html().ok())
|
||||
}
|
||||
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
@@ -247,7 +430,7 @@ impl Services for Desktop {
|
||||
}
|
||||
}
|
||||
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let img = self.clipboard.as_mut()?.get_image().ok()?;
|
||||
let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?;
|
||||
let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
|
||||
// Refuse absurd sizes (and mismatched buffers) rather than allocate.
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 {
|
||||
@@ -278,6 +461,13 @@ impl Services for Desktop {
|
||||
fn reduce_motion(&self) -> bool {
|
||||
self.reduce_motion.load(Ordering::Relaxed)
|
||||
}
|
||||
fn system_dark(&self) -> Option<bool> {
|
||||
match self.dark.load(Ordering::Relaxed) {
|
||||
DARK_DARK => Some(true),
|
||||
DARK_LIGHT => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn gamepad_held(&mut self) -> u32 {
|
||||
crate::gamepad::held()
|
||||
}
|
||||
@@ -297,3 +487,24 @@ pub fn window_icon() -> Option<egui::IconData> {
|
||||
let (w, h) = img.dimensions();
|
||||
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod save_filter_tests {
|
||||
use super::save_filters;
|
||||
|
||||
#[test]
|
||||
fn native_and_fig_save_filters_are_separate_with_native_first() {
|
||||
let extensions = ["figcraft", "fig"];
|
||||
assert_eq!(
|
||||
save_filters(&extensions),
|
||||
vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_save_filters_keep_one_combined_group() {
|
||||
for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] {
|
||||
assert_eq!(save_filters(extensions), vec![("File", extensions)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +82,63 @@ fn preferences_are_readable_by_their_owner_only() {
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A live link that only records the sessions it was told to leave.
|
||||
struct Leaves(Arc<Mutex<Vec<u64>>>);
|
||||
|
||||
impl figcraft_engine::live::LiveLink for Leaves {
|
||||
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
|
||||
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
|
||||
let dir = root("shutdown");
|
||||
let mut fig = start(&dir);
|
||||
let left = Arc::new(Mutex::new(Vec::new()));
|
||||
fig.session.live.install(Box::new(Leaves(left.clone())));
|
||||
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
|
||||
fig.session.prefs.user_name = "Ada".into();
|
||||
crate::services::shutdown(&mut fig, Some(&dir), None);
|
||||
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
|
||||
assert!(!fig.session.live.available(), "nothing is left to send on");
|
||||
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself.
|
||||
#[test]
|
||||
fn the_next_dialog_starts_in_the_folder_of_the_last_pick() {
|
||||
use crate::services::{folder_to_remember, remembering, start_folder};
|
||||
use std::path::PathBuf;
|
||||
let dir = root("last-folder");
|
||||
let file = dir.join("Poster.figcraft");
|
||||
assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder");
|
||||
assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself");
|
||||
assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder");
|
||||
|
||||
let last = Arc::new(Mutex::new(None::<PathBuf>));
|
||||
let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false);
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file)));
|
||||
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered");
|
||||
let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false);
|
||||
assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None));
|
||||
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder");
|
||||
|
||||
assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own");
|
||||
}
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
//! report it, instead of the app quitting silently (#261).
|
||||
|
||||
use figcraft_ui_egui::dialogs::window_failed as message;
|
||||
use figcraft_ui_egui::i18n::tr;
|
||||
|
||||
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
|
||||
const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.";
|
||||
const DRIVER_HINT: &str = if cfg!(windows) {
|
||||
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:"
|
||||
} else {
|
||||
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
@@ -15,9 +22,9 @@ fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
#[test]
|
||||
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("graphics driver"), "{text}");
|
||||
assert!(text.contains(tr(DRIVER_HINT)), "{text}");
|
||||
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
|
||||
assert!(!text.contains("unsetting"), "{text}");
|
||||
assert!(!text.contains(tr(BACKEND_HINT)), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -30,6 +37,6 @@ fn on_windows_it_shows_how_to_start_with_opengl() {
|
||||
#[test]
|
||||
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
|
||||
let text = message(ERROR, true);
|
||||
assert!(text.contains("unsetting"), "{text}");
|
||||
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
|
||||
assert!(text.contains(tr(BACKEND_HINT)), "{text}");
|
||||
assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}");
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ display_name = "VanTan"
|
||||
real_name = "Tyler Jennings"
|
||||
display_name = "tylerjenningsw"
|
||||
|
||||
[people.SiadeLucas]
|
||||
real_name = "Siade Lucas"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
@@ -37,6 +41,11 @@ real_name = "Justin John"
|
||||
|
||||
[people.Losminek]
|
||||
|
||||
[people.alrifatsabbir]
|
||||
real_name = "Al Rifat Sabbir"
|
||||
display_name = "Al Rifat Sabbir"
|
||||
sync_github_name = true
|
||||
|
||||
[people.RevDonz]
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
@@ -44,3 +53,39 @@ sync_github_name = true
|
||||
|
||||
[people.volkovskey]
|
||||
real_name = "Andrii Volkov"
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.pechnikov2026-star]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
|
||||
[people.mojtabajafarzadeh]
|
||||
real_name = "Mojtaba Jafarzadeh"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.leandrodslv]
|
||||
sync_github_name = true
|
||||
|
||||
[people.mateusvillain]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
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);
|
||||
// The port is free again: another program's host may have taken it, and that host refuses
|
||||
// the code's secret.
|
||||
let label = late.live().status.label();
|
||||
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -103,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
|
||||
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' };
|
||||
// The last symbol contains only checksum bits. Changing payload bits can collide with
|
||||
// the eight-bit checksum, so it does not guarantee a malformed code.
|
||||
let last = typo.last_mut().unwrap();
|
||||
*last = if *last == '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.
|
||||
@@ -141,7 +147,9 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
|
||||
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();
|
||||
// read() returns Result<Message>, not a lock guard; this test server deliberately
|
||||
// ignores whatever the client sent first.
|
||||
let _ = ws.read(); // NOSONAR
|
||||
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);
|
||||
|
||||
@@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
@@ -114,6 +114,9 @@ impl<S: Socket> Driver<S> {
|
||||
/// A binary message from the host.
|
||||
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
if bytes.len() > self.cfg.limits.max_server_message {
|
||||
return self.end(too_large(&self.cfg.limits), now);
|
||||
}
|
||||
c.last_heard = now;
|
||||
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
// The join code's answer: join the file it opens, on a new connection.
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN};
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
@@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() {
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
|
||||
}
|
||||
// A message over the size limit: the file is too large for this app, not refused by the host.
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let mut cfg = GuestConfig::new(invite(), "Ada".into());
|
||||
cfg.limits.max_server_message = 2 << 20;
|
||||
let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&vec![0; (2 << 20) + 1], t0);
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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, resolved, take, unresolved};
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::manage;
|
||||
use crate::net::{Duplex, NetLimits, config, open, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
@@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
|
||||
Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)),
|
||||
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
|
||||
Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits),
|
||||
Err(e) => break Outcome::Retry(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
+35
-1012
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
use super::limits::Admission;
|
||||
use super::*;
|
||||
|
||||
/// An admin request's reply, and what the hub must do about connections it affects.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Handled {
|
||||
pub reply: AdminReply,
|
||||
/// A link was revoked: `(file, link)`; whoever joined with it is disconnected.
|
||||
pub revoked: Option<(String, String)>,
|
||||
/// A file was removed: whoever is in it is disconnected and it is closed without saving.
|
||||
pub removed: Option<String>,
|
||||
/// An address was blocked ([`ip_key`]): its connections are dropped.
|
||||
pub blocked: Option<IpAddr>,
|
||||
}
|
||||
|
||||
impl From<AdminReply> for Handled {
|
||||
fn from(reply: AdminReply) -> Self {
|
||||
Self { reply, revoked: None, removed: None, blocked: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// What everyone is told when a hub stops.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Closing {
|
||||
/// The session is over (the app stopped hosting): final.
|
||||
Ended,
|
||||
/// The server is restarting: clients reconnect until it is back.
|
||||
Restarting,
|
||||
}
|
||||
|
||||
/// What the hub knows of an open file (for listings).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OpenFile {
|
||||
/// People connected to it.
|
||||
pub people: u32,
|
||||
/// Its latest sequence number.
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it
|
||||
/// from its own thread only.
|
||||
pub trait Backend: Send + 'static {
|
||||
/// A hello for `file` with `token`: what it may do, or why it may not join (the reason is
|
||||
/// shown to the person joining; never echo the token).
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String>;
|
||||
|
||||
/// The session for a file that isn't open (after [`admit`](Self::admit) let someone in).
|
||||
fn open(&mut self, file: &str) -> Result<Server, String>;
|
||||
|
||||
/// The hub handled a message for an open file: keep what changed (its server's journal).
|
||||
fn changed(&mut self, _file: &str, _server: &mut Server) {}
|
||||
|
||||
/// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers
|
||||
/// after this. The files whose changes couldn't be made durable: nobody hears of them, the
|
||||
/// files are closed (the backend forgets their open state) and everyone in them reconnects.
|
||||
fn flush(&mut self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Whether `file` has room for a batch of about `bytes` (its size on the wire): why not, and
|
||||
/// the batch is refused.
|
||||
fn room(&mut self, _file: &str, _server: &Server, _bytes: usize) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everyone left `file`: save it. True closes it (the next hello opens it again).
|
||||
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The hub is stopping: save the open files.
|
||||
fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {}
|
||||
|
||||
/// A managing connection's key: whether it may manage files.
|
||||
fn manage(&mut self, _key: &str) -> Result<(), String> {
|
||||
Err("this session is hosted by an app: it has no files to manage".into())
|
||||
}
|
||||
|
||||
/// A request on a managing connection; `open` tells about a file that is open.
|
||||
fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
AdminReply::Failed { reason: "nothing to manage here".into() }.into()
|
||||
}
|
||||
|
||||
/// A request on a library connection to `file`, opened with a link of `access` (checked by
|
||||
/// [`admit`](Self::admit)).
|
||||
fn library(&mut self, _ip: IpAddr, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply {
|
||||
LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() }
|
||||
}
|
||||
|
||||
/// A request on a plugin directory connection; `publisher`: it was opened with the right key
|
||||
/// (checked by [`manage`](Self::manage)).
|
||||
fn plugins(&mut self, _ip: IpAddr, _publisher: bool, _req: PluginReq) -> PluginReply {
|
||||
PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() }
|
||||
}
|
||||
|
||||
/// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the
|
||||
/// client).
|
||||
fn connect(&mut self, _ip: IpAddr) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A message from a connection from `ip`: false disconnects it (that address sends too much).
|
||||
fn message(&mut self, _ip: IpAddr, _msg: &ClientMsg) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// What to tell a client that reads it about the server's hosting, after its welcome to
|
||||
/// `file` (`None`: nothing).
|
||||
fn hosting(&mut self, _file: &str) -> Option<HostInfo> {
|
||||
None
|
||||
}
|
||||
|
||||
/// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty:
|
||||
/// what the server says about its hosting, or why the connection is refused.
|
||||
fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result<HostInfo, String> {
|
||||
Err("this session is hosted by an app: it doesn't keep files for anyone".into())
|
||||
}
|
||||
|
||||
/// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it
|
||||
/// was opened with.
|
||||
fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
AdminReply::Failed { reason: "nothing to manage here".into() }.into()
|
||||
}
|
||||
|
||||
/// Someone in `file` (joined with `link`, from `ip`) reports it to the operator.
|
||||
fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> {
|
||||
Err("this session is hosted by an app: tell the person hosting it".into())
|
||||
}
|
||||
|
||||
/// A join code typed by someone at `ip`: the file it opens and that link's token, or why not
|
||||
/// (shown to them; wrong codes are counted against the address).
|
||||
fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> {
|
||||
Err("this session is hosted by an app: join it with its own code or invite link".into())
|
||||
}
|
||||
|
||||
/// What everyone is told when the hub stops.
|
||||
fn closing(&self) -> Closing;
|
||||
}
|
||||
@@ -51,3 +51,47 @@ fn a_closed_file_forgets_whom_it_barred() {
|
||||
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
|
||||
assert!(hub.barred.contains_key("other"), "another file's stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kicked_connection_with_a_full_queue_is_shut() {
|
||||
use std::io::Read;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc::sync_channel;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{Conn, Kind, Line, Out};
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (server_side, _) = listener.accept().unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
// Stands in for the writer thread's handle: it keeps the socket open after the Conn is gone.
|
||||
let _writer = server_side.try_clone().unwrap();
|
||||
let (tx, _rx) = sync_channel(2);
|
||||
while tx.try_send(Out::Bytes(Default::default(), None)).is_ok() {}
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
block: Arc::new(AtomicUsize::new(0)),
|
||||
all: Arc::new(AtomicUsize::new(0)),
|
||||
max: 1 << 20,
|
||||
max_block: usize::MAX,
|
||||
max_all: usize::MAX,
|
||||
};
|
||||
let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
||||
let mut hub = Hub {
|
||||
in_flight: InFlight(Default::default()),
|
||||
backend: Closes,
|
||||
rooms: HashMap::new(),
|
||||
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
hub.kick(&[1], "kicked");
|
||||
assert!(hub.conns.is_empty());
|
||||
let mut buf = [0u8; 1];
|
||||
assert_eq!(client.read(&mut buf).ok(), Some(0), "the kicked socket must be shut (client sees EOF)");
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
use super::hosting::{Open, spawn};
|
||||
use super::limits::{PerAddress, ip_key};
|
||||
use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block};
|
||||
use super::*;
|
||||
|
||||
/// How long a closing connection waits for the peer to hang up.
|
||||
const LINGER: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst;
|
||||
/// beyond that they are dropped.
|
||||
pub const SIGNAL_RATE: f64 = 20.0;
|
||||
|
||||
pub const SIGNAL_BURST: f64 = 40.0;
|
||||
|
||||
/// Longest request line an uptime check may have.
|
||||
const MAX_CHECK_LINE: usize = 2048;
|
||||
|
||||
/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed.
|
||||
/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket
|
||||
/// handshake goes on untouched.
|
||||
fn health_check(stream: &mut TcpStream, until: Instant) -> bool {
|
||||
let mut buf = [0u8; MAX_CHECK_LINE];
|
||||
let line = loop {
|
||||
let n = match stream.peek(&mut buf) {
|
||||
Ok(0) | Err(_) => return false,
|
||||
Ok(n) => n,
|
||||
};
|
||||
let seen = buf.get(..n).unwrap_or_default();
|
||||
if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) {
|
||||
return false;
|
||||
}
|
||||
if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") {
|
||||
break seen.get(..end).unwrap_or_default().to_vec();
|
||||
}
|
||||
if n == buf.len() || Instant::now() >= until {
|
||||
return false;
|
||||
}
|
||||
// The rest of the line is on its way (it nearly always arrives in one piece).
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default();
|
||||
let path = path.split(|b| *b == b'?').next().unwrap_or_default();
|
||||
if !path.ends_with(b"/healthz") {
|
||||
return false;
|
||||
}
|
||||
// Read the request (its headers end with a blank line), so closing doesn't reset it.
|
||||
let mut req = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 {
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
let _ = stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n");
|
||||
let _ = stream.shutdown(std::net::Shutdown::Write);
|
||||
true
|
||||
}
|
||||
|
||||
/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the
|
||||
/// proxy added; any before it are whatever the client sent).
|
||||
fn forwarded_for(req: &Request) -> Option<IpAddr> {
|
||||
let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?;
|
||||
let entry = last.rsplit(',').next()?.trim();
|
||||
entry.parse::<IpAddr>().ok().or_else(|| entry.parse::<SocketAddr>().ok().map(|a| a.ip()))
|
||||
}
|
||||
|
||||
/// What every connection shares: the count per address, the bytes queued for all of them and the
|
||||
/// bytes in flight to the hub.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Shared {
|
||||
pub(super) per_address: PerAddress,
|
||||
pub(super) queued: Arc<AtomicUsize>,
|
||||
pub(super) in_flight: InFlight,
|
||||
pub(super) blocks: Blocks,
|
||||
/// Bytes being read into messages, all connections together, and who is reading them.
|
||||
pub(super) reading: Arc<AtomicUsize>,
|
||||
pub(super) readers: Readers,
|
||||
}
|
||||
|
||||
pub(super) fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, limits: NetLimits, shared: Shared) {
|
||||
let mut next = 0u64;
|
||||
for stream in listener.incoming() {
|
||||
if stop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let stream = match stream {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Out of sockets or similar: don't spin.
|
||||
log::warn!("live session: accept failed: {e}");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if open.load(Ordering::Relaxed) >= limits.max_connections {
|
||||
log::info!("live session: too many connections; refusing one");
|
||||
continue;
|
||||
}
|
||||
open.fetch_add(1, Ordering::Relaxed);
|
||||
let guard = Open(open.clone());
|
||||
next += 1;
|
||||
let (conn, hub, shared) = (next, hub.clone(), shared.clone());
|
||||
if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &shared)) {
|
||||
log::warn!("live session: can't start a connection thread: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A close reason that fits a WebSocket close frame (123 bytes).
|
||||
pub(super) fn reason(s: &str) -> String {
|
||||
let mut end = s.len().min(123);
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s.get(..end).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
pub(super) fn encode(msg: &ServerMsg) -> Option<Bytes> {
|
||||
match message::encode(msg) {
|
||||
Ok(b) => Some(Bytes::from(b)),
|
||||
Err(e) => {
|
||||
log::warn!("live session: can't encode a message: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps).
|
||||
pub(super) fn refuse(out: &Line, why: &str) {
|
||||
refused(out, why);
|
||||
}
|
||||
|
||||
/// [`refuse`], and whether the close was queued (it isn't when the queue is full).
|
||||
pub(super) fn refused(out: &Line, why: &str) -> bool {
|
||||
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
|
||||
let _ = out.tx.try_send(Out::Bytes(b, None));
|
||||
}
|
||||
out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok()
|
||||
}
|
||||
|
||||
/// What the server tells a client when it is too busy to take more from it.
|
||||
const BUSY: &str = "the server is busy: try again in a moment";
|
||||
|
||||
/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end.
|
||||
fn next_message(ws: &mut WebSocket<Duplex>, max: usize, deadline: Option<Instant>) -> Result<Option<(ClientMsg, usize)>, String> {
|
||||
loop {
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => {
|
||||
// Whole now: it counts as in flight from here, no longer as being read.
|
||||
ws.get_mut().read_done();
|
||||
message::decode::<ClientMsg>(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string())
|
||||
}
|
||||
Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()),
|
||||
// Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and
|
||||
// a message trickled in is cut off by it (`Duplex::read_until`).
|
||||
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_))
|
||||
if deadline.is_some_and(|d| Instant::now() >= d) =>
|
||||
{
|
||||
Err("no hello in time".into())
|
||||
}
|
||||
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
|
||||
Ok(Message::Close(_)) => Ok(None),
|
||||
Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)),
|
||||
// The reading budget's refusal (see `Meter`).
|
||||
Err(tungstenite::Error::Io(e)) if e.kind() == io::ErrorKind::Other => Err(e.to_string()),
|
||||
Err(_) => Ok(None),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a message over `max` bytes is refused.
|
||||
fn too_large(max: usize) -> String {
|
||||
match max >> 20 {
|
||||
0 => format!("message too large: this server takes at most {} KiB at once here", max >> 10),
|
||||
mib => format!("message too large: this server takes at most {mib} MiB at once (a change that big can't be kept here)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection's reader (the writer runs on its own thread).
|
||||
fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, open: Open, shared: &Shared) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let hello_deadline = Instant::now() + limits.hello_timeout;
|
||||
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
}
|
||||
if health_check(&mut stream, Instant::now() + limits.hello_timeout) {
|
||||
return;
|
||||
}
|
||||
let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return };
|
||||
let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer);
|
||||
let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return };
|
||||
// The handshake and the hello must be in by the deadline, however slowly they trickle in.
|
||||
rd.read_until(Some(hello_deadline));
|
||||
let cfg = config(limits.max_client_message);
|
||||
let mut forwarded = None;
|
||||
// The handshake callback's type is tungstenite's (its error is an HTTP response).
|
||||
#[allow(clippy::result_large_err)]
|
||||
let read_client = |req: &Request, resp: Response| -> Result<Response, ErrorResponse> {
|
||||
if proxied {
|
||||
forwarded = forwarded_for(req);
|
||||
}
|
||||
Ok(resp)
|
||||
};
|
||||
let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) {
|
||||
Ok(ws) => ws,
|
||||
Err(e) => {
|
||||
log::debug!("live session: handshake failed: {e}");
|
||||
shutdown(&sock);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if proxied && forwarded.is_none() {
|
||||
log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For");
|
||||
}
|
||||
let ip = ip_key(forwarded.unwrap_or(peer));
|
||||
let counted = shared.per_address.enter_all(ip, limits.max_per_address, limits.max_connections);
|
||||
let wide = wide_block(ip);
|
||||
let block = shared.blocks.get(wide);
|
||||
ws.get_mut().meter(Meter::new(
|
||||
conn,
|
||||
&sock,
|
||||
&shared.readers,
|
||||
block.reading.clone(),
|
||||
shared.reading.clone(),
|
||||
limits.max_reading_block,
|
||||
limits.max_reading_total,
|
||||
));
|
||||
let (tx, rx) = sync_channel::<Out>(limits.queue.max(4));
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
block: block.queued.clone(),
|
||||
all: shared.queued.clone(),
|
||||
max: limits.max_queued_bytes,
|
||||
max_block: limits.max_queued_block,
|
||||
max_all: limits.max_queued_total,
|
||||
};
|
||||
let writer =
|
||||
match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) {
|
||||
Ok(Ok(t)) => t,
|
||||
_ => {
|
||||
shutdown(&sock);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let end = |why: Option<&str>| {
|
||||
if let Some(why) = why {
|
||||
refuse(&out, why);
|
||||
} else {
|
||||
let _ = out.tx.try_send(Out::Close(CloseCode::Normal, String::new()));
|
||||
}
|
||||
};
|
||||
if counted.is_none() {
|
||||
log::info!("event=refused ip={ip} reason=connections_per_address");
|
||||
end(Some("too many connections from your address: close some and try again"));
|
||||
} else {
|
||||
let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline));
|
||||
// In time or not, the hello's deadline is over: what's read next has timeouts of its own.
|
||||
ws.get_mut().read_until(None);
|
||||
match hello {
|
||||
Ok(Some((
|
||||
first @ (ClientMsg::Hello(_)
|
||||
| ClientMsg::Manage(_)
|
||||
| ClientMsg::OpenLibrary(_)
|
||||
| ClientMsg::OpenPlugins(_)
|
||||
| ClientMsg::OpenHosting(_)
|
||||
| ClientMsg::Resolve(_)),
|
||||
bytes,
|
||||
))) => {
|
||||
// Sessions and the operator may use the last connections; one-shot requests
|
||||
// (hosting, libraries, browsing plugins) leave them free.
|
||||
let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_));
|
||||
// A publish uploads a whole file; a large one on a slow link takes the server a
|
||||
// while to receive and answer. The manage/hosting connections that carry uploads
|
||||
// wait the longer request-reply timeout, not the short request idle.
|
||||
let idle = match first {
|
||||
ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout,
|
||||
ClientMsg::Hello(_) => limits.idle_timeout,
|
||||
_ => limits.request_idle,
|
||||
};
|
||||
let max = match &first {
|
||||
ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request,
|
||||
_ => limits.max_client_message,
|
||||
};
|
||||
let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions);
|
||||
// (In flight is only taken when the connection goes on: the hub gives it back.)
|
||||
if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) {
|
||||
end(Some(BUSY));
|
||||
} else {
|
||||
let (admitted, verdict) = sync_channel(1);
|
||||
let opened =
|
||||
sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok());
|
||||
// Nothing more is read until the hub took the connection in (its token checked):
|
||||
// only then may it send messages of the full size.
|
||||
if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() {
|
||||
ws.set_config(|c| *c = config(max));
|
||||
read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end);
|
||||
let _ = hub.send(HubIn::Gone { conn });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => end(Some("say hello first")),
|
||||
Ok(None) => {}
|
||||
Err(why) => end(Some(&why)),
|
||||
}
|
||||
}
|
||||
drop(counted);
|
||||
// The block's counters go with the last handle on them (the table forgets it then).
|
||||
drop(block);
|
||||
ws.get_mut().read_until(None);
|
||||
linger(&mut ws);
|
||||
drop(out);
|
||||
// The writer closes the socket once it has sent what's queued (a refusal, a close).
|
||||
let _ = writer.join();
|
||||
shutdown(&sock);
|
||||
}
|
||||
|
||||
fn read_loop(
|
||||
ws: &mut WebSocket<Duplex>,
|
||||
conn: u64,
|
||||
hub: &Sender<HubIn>,
|
||||
limits: NetLimits,
|
||||
max: usize,
|
||||
in_flight: &InFlight,
|
||||
end: &dyn Fn(Option<&str>),
|
||||
) {
|
||||
let mut bucket = Bucket::new(limits.rate, limits.burst);
|
||||
let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST);
|
||||
loop {
|
||||
match next_message(ws, max, None) {
|
||||
Ok(Some((msg, bytes))) => {
|
||||
let now = Instant::now();
|
||||
if !bucket.take(now) {
|
||||
log::info!("live session: connection {conn} sends too much; disconnecting it");
|
||||
return end(Some("too many messages"));
|
||||
}
|
||||
// Typing sends a chat signal per keystroke; more than this is dropped.
|
||||
if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) {
|
||||
log::debug!("live session: connection {conn} sends signals too fast; dropping one");
|
||||
continue;
|
||||
}
|
||||
if !in_flight.take(bytes, limits.max_in_flight) {
|
||||
log::info!("live session: the hub is behind; dropping connection {conn}");
|
||||
return end(Some(BUSY));
|
||||
}
|
||||
if hub.send(HubIn::Msg { conn, msg, bytes }).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => return end(None),
|
||||
Err(why) => return end(Some(&why)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_loop(mut ws: WebSocket<Duplex>, rx: &Receiver<Out>, sock: &TcpStream) {
|
||||
let mut close = Some((CloseCode::Normal, String::new()));
|
||||
while let Ok(o) = rx.recv() {
|
||||
match o {
|
||||
Out::Bytes(b, _charge) => {
|
||||
if ws.send(Message::Binary(b)).is_err() {
|
||||
close = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Out::Close(code, why) => {
|
||||
close = Some((code, why));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((code, why)) = close {
|
||||
// Close politely: the close frame, then no more writing; the reader drains what the peer
|
||||
// still sends until it hangs up (closing on unread data would reset the connection and
|
||||
// could lose the close frame), or until the linger runs out.
|
||||
let _ = ws.close(Some(CloseFrame { code, reason: why.into() }));
|
||||
let _ = sock.shutdown(std::net::Shutdown::Write);
|
||||
let until = Instant::now() + LINGER;
|
||||
while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
|
||||
if Instant::now() >= until {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
shutdown(sock);
|
||||
}
|
||||
|
||||
/// Read and drop what the peer still sends after the close, until it hangs up (at most
|
||||
/// [`LINGER`]). Raw bytes, not frames: after a frame over the size limit the WebSocket's reader
|
||||
/// is mid-frame and mustn't be asked for more.
|
||||
fn linger(ws: &mut WebSocket<Duplex>) {
|
||||
let until = Instant::now() + LINGER;
|
||||
let mut buf = [0u8; 4096];
|
||||
while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() {
|
||||
match ws.get_mut().read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::backend::Backend;
|
||||
use super::conn::{Shared, accept};
|
||||
use super::limits::{PerAddress, loopback};
|
||||
use super::queue::{Blocks, HubIn, InFlight};
|
||||
use super::*;
|
||||
|
||||
/// The person hosting an app session: in the file's session without a socket.
|
||||
pub(crate) struct Local {
|
||||
pub file: String,
|
||||
pub server: Server,
|
||||
/// Their client id (they joined `server` already).
|
||||
pub id: u32,
|
||||
pub inbox: Inbox,
|
||||
}
|
||||
|
||||
pub(super) fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result<JoinHandle<()>> {
|
||||
std::thread::Builder::new().name(name.to_string()).spawn(f)
|
||||
}
|
||||
|
||||
/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters.
|
||||
pub(crate) fn clean_name(s: &str) -> String {
|
||||
s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect()
|
||||
}
|
||||
|
||||
/// A running hub: its listener, connections and hub thread. Dropping it stops it.
|
||||
pub struct Hosting {
|
||||
addr: SocketAddr,
|
||||
hub: Sender<HubIn>,
|
||||
stop: Arc<AtomicBool>,
|
||||
open: Arc<AtomicUsize>,
|
||||
/// Bytes queued for every connection together, and in flight to the hub.
|
||||
queued: Arc<AtomicUsize>,
|
||||
in_flight: InFlight,
|
||||
blocks: Blocks,
|
||||
reading: Arc<AtomicUsize>,
|
||||
hub_thread: Option<JoinHandle<()>>,
|
||||
accept_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Hosting {
|
||||
/// Serve `backend`'s files on `listener`.
|
||||
pub fn serve<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result<Hosting> {
|
||||
Self::start(listener, backend, limits, None)
|
||||
}
|
||||
|
||||
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
|
||||
let addr = listener.local_addr()?;
|
||||
let in_flight = InFlight(Arc::default());
|
||||
let mut hub = Hub {
|
||||
backend,
|
||||
rooms: HashMap::new(),
|
||||
conns: HashMap::new(),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
in_flight: in_flight.clone(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
if let Some(l) = local {
|
||||
let mut room = Room::new(l.server);
|
||||
room.local = Some((l.id, l.inbox));
|
||||
hub.rooms.insert(l.file.clone(), room);
|
||||
hub.local = Some(l.file);
|
||||
}
|
||||
let (hub_tx, hub_rx) = channel();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?;
|
||||
let shared = Shared {
|
||||
per_address: PerAddress::default(),
|
||||
queued: Arc::default(),
|
||||
in_flight: in_flight.clone(),
|
||||
blocks: Blocks::default(),
|
||||
reading: Arc::default(),
|
||||
readers: Readers::default(),
|
||||
};
|
||||
let queued = shared.queued.clone();
|
||||
let blocks = shared.blocks.clone();
|
||||
let reading = shared.reading.clone();
|
||||
let accept_thread = {
|
||||
let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone());
|
||||
spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits, shared))
|
||||
};
|
||||
let accept_thread = match accept_thread {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = hub_tx.send(HubIn::Stop);
|
||||
let _ = hub_thread.join();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
log::info!("live session: listening on {addr}");
|
||||
Ok(Hosting {
|
||||
addr,
|
||||
hub: hub_tx,
|
||||
stop,
|
||||
open,
|
||||
queued,
|
||||
in_flight,
|
||||
blocks,
|
||||
reading,
|
||||
hub_thread: Some(hub_thread),
|
||||
accept_thread: Some(accept_thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes waiting to be sent to clients, all connections together.
|
||||
pub fn queued_bytes(&self) -> usize {
|
||||
self.queued.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Blocks of addresses the server keeps counters for (those of open connections, and any
|
||||
/// whose last connection just ended, until the next connection prunes them).
|
||||
pub fn blocks_remembered(&self) -> usize {
|
||||
self.blocks.len()
|
||||
}
|
||||
|
||||
/// Bytes being read into messages not whole yet, all connections together.
|
||||
pub fn reading_bytes(&self) -> usize {
|
||||
self.reading.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Bytes of messages read and not handled by the hub yet.
|
||||
pub fn in_flight_bytes(&self) -> usize {
|
||||
self.in_flight.0.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Where it listens.
|
||||
pub fn addr(&self) -> SocketAddr {
|
||||
self.addr
|
||||
}
|
||||
|
||||
/// Open connections (joined or not).
|
||||
pub fn connections(&self) -> usize {
|
||||
self.open.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The hosting person's presence (app sessions).
|
||||
pub(crate) fn presence(&self, p: Presence) {
|
||||
let _ = self.hub.send(HubIn::Local(p));
|
||||
}
|
||||
|
||||
/// A message from the hosting person's sync client (their edits).
|
||||
pub(crate) fn send(&self, msg: ClientMsg) {
|
||||
let _ = self.hub.send(HubIn::LocalMsg(msg));
|
||||
}
|
||||
|
||||
/// The hosting person removes client `id` from their file (app sessions; see
|
||||
/// [`HubIn::Remove`]).
|
||||
pub(crate) fn remove(&self, id: u32) {
|
||||
let _ = self.hub.send(HubIn::Remove(id));
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
let _ = self.hub.send(HubIn::Cut);
|
||||
}
|
||||
|
||||
/// Stop: the backend saves what's open, everyone is told and disconnected, the port is
|
||||
/// released.
|
||||
pub fn stop(&mut self) {
|
||||
if self.stop.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let _ = self.hub.send(HubIn::Stop);
|
||||
if let Some(t) = self.hub_thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
// The accept loop checks the flag after each connection: wake it with one.
|
||||
let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr };
|
||||
let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok();
|
||||
if let Some(t) = self.accept_thread.take() {
|
||||
if woke {
|
||||
let _ = t.join();
|
||||
} else {
|
||||
log::warn!("live session: couldn't wake the accept loop; it ends with the next connection");
|
||||
}
|
||||
}
|
||||
// Readers end once their writers have closed the sockets.
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
log::info!("live session: stopped listening on {}", self.addr);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Hosting {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts an open connection until dropped.
|
||||
pub(super) struct Open(pub(super) Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Open {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::*;
|
||||
|
||||
/// What a link lets a hello do.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Admission {
|
||||
pub access: Access,
|
||||
/// The link's id, when the backend has several per file (revoking it disconnects whoever
|
||||
/// joined with it).
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address
|
||||
/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses).
|
||||
pub fn ip_key(ip: IpAddr) -> IpAddr {
|
||||
match ip.to_canonical() {
|
||||
IpAddr::V6(v6) => {
|
||||
let [a, b, c, d, ..] = v6.segments();
|
||||
IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0))
|
||||
}
|
||||
v4 => v4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The loopback address of `addr`'s family.
|
||||
pub(super) fn loopback(addr: SocketAddr) -> std::net::IpAddr {
|
||||
if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() }
|
||||
}
|
||||
|
||||
/// A block of addresses: its first address and its prefix length (2001:db8:: as a /64 and as a
|
||||
/// /48 are different blocks).
|
||||
pub type Block = (IpAddr, u8);
|
||||
|
||||
/// The blocks a client's address ([`ip_key`]) counts in, with how many times its own limits each
|
||||
/// gets: the address itself (an IPv4 /32 or an IPv6 /64, ×1) and, for IPv6, its /56 (×2) and /48
|
||||
/// (×4), so a tunnel's free /48 doesn't multiply the limits 65,536 times.
|
||||
pub fn ip_blocks(key: IpAddr) -> Vec<(Block, u32)> {
|
||||
let IpAddr::V6(v6) = key else { return vec![((key, 32), 1)] };
|
||||
let [a, b, c, d, ..] = v6.segments();
|
||||
vec![
|
||||
((IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)), 64), 1),
|
||||
((IpAddr::V6(Ipv6Addr::new(a, b, c, d & 0xff00, 0, 0, 0, 0)), 56), 2),
|
||||
((IpAddr::V6(Ipv6Addr::new(a, b, c, 0, 0, 0, 0, 0)), 48), 4),
|
||||
]
|
||||
}
|
||||
|
||||
/// Open connections per client address ([`ip_key`], and its wider IPv6 blocks); an address
|
||||
/// leaves when its last one closes, so the table never holds more than the open connections.
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct PerAddress(pub(super) Arc<Mutex<HashMap<Block, usize>>>);
|
||||
|
||||
/// One connection counted for a block until dropped.
|
||||
pub(super) struct Counted {
|
||||
table: PerAddress,
|
||||
ip: Block,
|
||||
}
|
||||
|
||||
impl PerAddress {
|
||||
/// Count a connection from block `ip`, unless it has `cap` already (0: no cap).
|
||||
fn enter(&self, ip: Block, cap: usize) -> Option<Counted> {
|
||||
let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let n = t.entry(ip).or_insert(0);
|
||||
if cap > 0 && *n >= cap {
|
||||
if *n == 0 {
|
||||
t.remove(&ip);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
*n += 1;
|
||||
Some(Counted { table: self.clone(), ip })
|
||||
}
|
||||
|
||||
/// Count a connection from `ip` in each of its blocks, unless one of them is full. A wider
|
||||
/// block's cap is its multiple of `cap`, but at most a sixteenth (/56) or an eighth (/48) of
|
||||
/// `all` connections, so no block takes every connection.
|
||||
pub(super) fn enter_all(&self, ip: IpAddr, cap: usize, all: usize) -> Option<Vec<Counted>> {
|
||||
let mut held = Vec::new();
|
||||
for ((addr, prefix), times) in ip_blocks(ip) {
|
||||
let block_cap = match prefix {
|
||||
56 => cap.saturating_mul(times as usize).min(all / 16).max(1),
|
||||
48 => cap.saturating_mul(times as usize).min(all / 8).max(1),
|
||||
_ => cap,
|
||||
};
|
||||
held.push(self.enter((addr, prefix), if cap == 0 { 0 } else { block_cap })?);
|
||||
}
|
||||
Some(held)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Counted {
|
||||
fn drop(&mut self) {
|
||||
let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(n) = t.get_mut(&self.ip) {
|
||||
*n = n.saturating_sub(1);
|
||||
if *n == 0 {
|
||||
t.remove(&self.ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! A connection whose hello the backend hasn't admitted yet is held to the hello's size limit: it
|
||||
//! can't have a message of the full size read while its token is unchecked.
|
||||
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Server};
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use super::{Admission, Backend, Closing, Hosting};
|
||||
use crate::net::NetLimits;
|
||||
|
||||
/// A backend that refuses every hello, once it is told to.
|
||||
struct Slow(Receiver<()>);
|
||||
|
||||
impl Backend for Slow {
|
||||
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
|
||||
let _ = self.0.recv();
|
||||
Err("wrong token".into())
|
||||
}
|
||||
|
||||
fn open(&mut self, _file: &str) -> Result<Server, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Ended
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(addr: SocketAddr) -> tungstenite::WebSocket<TcpStream> {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unadmitted_connection_cant_send_a_large_message() {
|
||||
let limits = NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() };
|
||||
let (release, wait) = channel();
|
||||
let mut hosting = Hosting::serve(TcpListener::bind("127.0.0.1:0").unwrap(), Slow(wait), limits).unwrap();
|
||||
let addr = hosting.addr();
|
||||
let sender = std::thread::spawn(move || {
|
||||
let mut ws = connect(addr);
|
||||
let hello = message::encode(&Client::hello("0123456789abcdef", "bad", "x", "")).unwrap();
|
||||
ws.send(Message::Binary(Bytes::from(hello))).unwrap();
|
||||
let big = ClientMsg::Signal(Signal { kind: Signal::CHAT.into(), text: "x".repeat(1 << 20) });
|
||||
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&big).unwrap())));
|
||||
let mut refused = None;
|
||||
while let Ok(m) = ws.read() {
|
||||
if let Message::Binary(b) = m
|
||||
&& let Ok(ServerMsg::Refused { reason }) = message::decode(&b, 64 << 20)
|
||||
{
|
||||
refused = Some(reason);
|
||||
}
|
||||
}
|
||||
refused
|
||||
});
|
||||
let mut peak = 0;
|
||||
let until = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < until {
|
||||
peak = peak.max(hosting.in_flight_bytes());
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
release.send(()).unwrap();
|
||||
let refused = sender.join().unwrap();
|
||||
hosting.stop();
|
||||
assert!(peak <= limits.max_hello, "a message of {peak} bytes was read before the token was checked");
|
||||
assert_eq!(refused.as_deref(), Some("wrong token"));
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use super::limits::ip_blocks;
|
||||
use super::*;
|
||||
|
||||
/// A message for one connection's writer: bytes (with what they count against, released when
|
||||
/// they are sent or dropped), or the close.
|
||||
pub(crate) enum Out {
|
||||
Bytes(Bytes, Option<Charge>),
|
||||
Close(CloseCode, String),
|
||||
}
|
||||
|
||||
/// One message's bytes held for one or more connections (a broadcast shares one buffer), counted
|
||||
/// once against a shared counter (the server's, or a block's) until the last copy is sent or
|
||||
/// dropped.
|
||||
pub(super) struct Held {
|
||||
n: usize,
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Held {
|
||||
fn new(n: usize, counter: &Arc<AtomicUsize>) -> Arc<Held> {
|
||||
counter.fetch_add(n, Ordering::Relaxed);
|
||||
Arc::new(Held { n, counter: counter.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Held {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(self.n, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// What one message counts against, made by its first recipient and shared by the others: the
|
||||
/// server's budget once, and each recipient block's once (twenty people behind one office's
|
||||
/// address cost their block one copy of a broadcast, not twenty).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Charges {
|
||||
pub(super) all: Option<Arc<Held>>,
|
||||
pub(super) blocks: Vec<(usize, Arc<Held>)>,
|
||||
/// Count it against blocks at all: a welcome isn't (it is bounded by the file's size and each
|
||||
/// connection's cap, and a whole office joining at once must fit).
|
||||
pub(crate) skip_blocks: bool,
|
||||
}
|
||||
|
||||
/// Bytes waiting in a connection's queue, counted against its cap until dropped (and, through
|
||||
/// [`Held`], once against the server's budget and once against its block's).
|
||||
pub(super) struct Charge {
|
||||
n: usize,
|
||||
mine: Arc<AtomicUsize>,
|
||||
_all: Arc<Held>,
|
||||
_block: Option<Arc<Held>>,
|
||||
}
|
||||
|
||||
impl Drop for Charge {
|
||||
fn drop(&mut self) {
|
||||
self.mine.fetch_sub(self.n, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a message couldn't be queued.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Push {
|
||||
/// The connection's or its block's queue is full: it can't keep up.
|
||||
Full,
|
||||
/// The server's budget for queued bytes is used up.
|
||||
Everyone,
|
||||
/// The connection is gone.
|
||||
Gone,
|
||||
}
|
||||
|
||||
/// One connection's way out: its writer's queue, capped in messages and in bytes (its own, its
|
||||
/// block of addresses', and the server's for everyone), so a client that doesn't read can't make
|
||||
/// the server hold more than that for it.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Line {
|
||||
pub(crate) tx: SyncSender<Out>,
|
||||
pub(crate) queued: Arc<AtomicUsize>,
|
||||
pub(crate) block: Arc<AtomicUsize>,
|
||||
pub(crate) all: Arc<AtomicUsize>,
|
||||
pub(crate) max: usize,
|
||||
pub(crate) max_block: usize,
|
||||
pub(crate) max_all: usize,
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
|
||||
/// gets out). `charges` is what the message counts against, shared by its recipients;
|
||||
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
|
||||
/// to make room).
|
||||
pub(crate) fn push(&self, b: &Bytes, charges: &mut Charges, over_budget: bool) -> Result<(), Push> {
|
||||
let n = b.len();
|
||||
let mine = self.queued.load(Ordering::Relaxed);
|
||||
if mine > 0 && mine.saturating_add(n) > self.max {
|
||||
return Err(Push::Full);
|
||||
}
|
||||
let key = Arc::as_ptr(&self.block) as usize;
|
||||
let block = if charges.skip_blocks {
|
||||
None
|
||||
} else if let Some((_, h)) = charges.blocks.iter().find(|(k, _)| *k == key) {
|
||||
Some(h.clone())
|
||||
} else {
|
||||
let used = self.block.load(Ordering::Relaxed);
|
||||
if used > 0 && used.saturating_add(n) > self.max_block {
|
||||
return Err(Push::Full);
|
||||
}
|
||||
let h = Held::new(n, &self.block);
|
||||
charges.blocks.push((key, h.clone()));
|
||||
Some(h)
|
||||
};
|
||||
let all = match &charges.all {
|
||||
Some(h) => h.clone(),
|
||||
None => {
|
||||
if !over_budget && self.all.load(Ordering::Relaxed).saturating_add(n) > self.max_all {
|
||||
return Err(Push::Everyone);
|
||||
}
|
||||
charges.all.insert(Held::new(n, &self.all)).clone()
|
||||
}
|
||||
};
|
||||
self.queued.fetch_add(n, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
|
||||
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
|
||||
TrySendError::Full(_) => Push::Full,
|
||||
TrySendError::Disconnected(_) => Push::Gone,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What one block of addresses (an IPv4 address, an IPv6 /48: [`wide_block`]) has queued and is
|
||||
/// having read, shared by its connections.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct BlockUse {
|
||||
pub(super) queued: Arc<AtomicUsize>,
|
||||
pub(super) reading: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
/// The blocks with open connections; a block leaves when its last connection ends.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Blocks(pub(crate) Arc<Mutex<HashMap<IpAddr, BlockUse>>>);
|
||||
|
||||
impl Blocks {
|
||||
/// Block `key`'s counters; blocks nobody uses any more (only the table holds their counters,
|
||||
/// whose connections' queues and readers are all gone) are forgotten first, so the table
|
||||
/// never holds more than the blocks of open connections.
|
||||
pub(crate) fn get(&self, key: IpAddr) -> BlockUse {
|
||||
let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
t.retain(|_, u| Arc::strong_count(&u.queued) > 1 || Arc::strong_count(&u.reading) > 1);
|
||||
t.entry(key).or_default().clone()
|
||||
}
|
||||
|
||||
/// Blocks remembered.
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest block an address counts in for the server's shared budgets: an IPv4 address, or an
|
||||
/// IPv6 address's /48.
|
||||
pub fn wide_block(key: IpAddr) -> IpAddr {
|
||||
ip_blocks(key).last().map_or(key, |(b, _)| b.0)
|
||||
}
|
||||
|
||||
/// Messages handed to the hub thread and not handled yet, in bytes: past the budget a reader
|
||||
/// drops its connection instead of piling more on a busy hub.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct InFlight(pub(crate) Arc<AtomicUsize>);
|
||||
|
||||
impl InFlight {
|
||||
pub(super) fn take(&self, n: usize, max: usize) -> bool {
|
||||
if self.0.fetch_add(n, Ordering::Relaxed).saturating_add(n) > max {
|
||||
self.0.fetch_sub(n, Ordering::Relaxed);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn give(&self, n: usize) {
|
||||
self.0.fetch_sub(n, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum HubIn {
|
||||
/// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`,
|
||||
/// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]).
|
||||
Open {
|
||||
conn: u64,
|
||||
ip: IpAddr,
|
||||
first: ClientMsg,
|
||||
out: Line,
|
||||
sock: TcpStream,
|
||||
/// Its size (in flight until handled).
|
||||
bytes: usize,
|
||||
/// Told when the hub takes the connection in (dropped unsent when it doesn't).
|
||||
admitted: SyncSender<()>,
|
||||
},
|
||||
Msg {
|
||||
conn: u64,
|
||||
msg: ClientMsg,
|
||||
bytes: usize,
|
||||
},
|
||||
Gone {
|
||||
conn: u64,
|
||||
},
|
||||
Local(Presence),
|
||||
LocalMsg(ClientMsg),
|
||||
/// The person hosting removes client `id` from their file (app sessions).
|
||||
Remove(u32),
|
||||
/// Cut every connection without a word (tests: a network failure).
|
||||
#[cfg(test)]
|
||||
Cut,
|
||||
Stop,
|
||||
}
|
||||
@@ -11,11 +11,18 @@ use figcraft_engine::live::{Event, Presence};
|
||||
pub type Waker = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// Most discrete events kept between two drains (joins, leaves, status); the oldest go first.
|
||||
/// Document sync events don't count and are never dropped: the sync client treats a missing
|
||||
/// batch as a fatal sequence gap, so losing one would end the session.
|
||||
const MAX_EVENTS: usize = 10_000;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
events: VecDeque<Event>,
|
||||
/// Arrival number of the next event, so [`Inbox::drain`] can merge the two queues in order.
|
||||
next: u64,
|
||||
/// [`Event::Sync`] events, never dropped.
|
||||
syncs: VecDeque<(u64, Event)>,
|
||||
/// Every other event, capped at [`MAX_EVENTS`].
|
||||
others: VecDeque<(u64, Event)>,
|
||||
presence: BTreeMap<u32, Presence>,
|
||||
}
|
||||
|
||||
@@ -43,11 +50,17 @@ impl Inbox {
|
||||
if let Event::PeerLeft(id) = &e {
|
||||
s.presence.remove(id);
|
||||
}
|
||||
if s.events.len() >= MAX_EVENTS {
|
||||
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
|
||||
s.events.pop_front();
|
||||
let n = s.next;
|
||||
s.next += 1;
|
||||
if matches!(e, Event::Sync(_)) {
|
||||
s.syncs.push_back((n, e));
|
||||
return;
|
||||
}
|
||||
s.events.push_back(e);
|
||||
if s.others.len() >= MAX_EVENTS {
|
||||
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
|
||||
s.others.pop_front();
|
||||
}
|
||||
s.others.push_back((n, e));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,8 +75,70 @@ impl Inbox {
|
||||
/// peer.
|
||||
pub fn drain(&self) -> Vec<figcraft_engine::live::Event> {
|
||||
let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut out: Vec<Event> = s.events.drain(..).collect();
|
||||
let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable();
|
||||
let mut others = std::mem::take(&mut s.others).into_iter().peekable();
|
||||
let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len());
|
||||
loop {
|
||||
let sync_first = match (syncs.peek(), others.peek()) {
|
||||
(Some((a, _)), Some((b, _))) => a < b,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => break,
|
||||
};
|
||||
let next = if sync_first { syncs.next() } else { others.next() };
|
||||
out.extend(next.map(|(_, e)| e));
|
||||
}
|
||||
out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p)));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use figcraft_collab::message::ServerMsg;
|
||||
|
||||
fn reject(batch_id: u64) -> Event {
|
||||
Event::Sync(ServerMsg::Reject { batch_id, reason: String::new(), fixes: Vec::new() })
|
||||
}
|
||||
|
||||
fn sync_ids(events: &[Event]) -> Vec<u64> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Event::Sync(ServerMsg::Reject { batch_id, .. }) => Some(*batch_id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_inbox_never_drops_sync_events() {
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
inbox.push(reject(1));
|
||||
for _ in 0..MAX_EVENTS {
|
||||
inbox.push(Event::PeerLeft(7));
|
||||
}
|
||||
inbox.push(reject(2));
|
||||
for _ in 0..MAX_EVENTS {
|
||||
inbox.push(Event::PeerLeft(7));
|
||||
}
|
||||
let out = inbox.drain();
|
||||
assert_eq!(sync_ids(&out), vec![1, 2]);
|
||||
assert!(out.len() <= MAX_EVENTS + 2, "{} events kept", out.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_events_past_the_cap_are_all_kept_in_order() {
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let n = MAX_EVENTS as u64 + 5;
|
||||
for id in 0..n {
|
||||
inbox.push(reject(id));
|
||||
}
|
||||
inbox.push(Event::PeerLeft(7));
|
||||
let out = inbox.drain();
|
||||
let ids = sync_ids(&out);
|
||||
assert_eq!(ids.len() as u64, n, "Sync events dropped");
|
||||
assert!(ids.iter().copied().eq(0..n), "Sync events out of order");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
|
||||
fn with_port(authority: &str, default: u16) -> String {
|
||||
let has_port = match authority.rsplit_once(':') {
|
||||
// `[v6]` alone has colons inside the brackets only.
|
||||
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
|
||||
Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'),
|
||||
None => false,
|
||||
};
|
||||
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
|
||||
@@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
let Some((name, port)) = s.rsplit_once(':') else { return false };
|
||||
port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
port.bytes().all(|b| b.is_ascii_digit())
|
||||
&& port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
&& (1..=253).contains(&name.len())
|
||||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
|
||||
&& !name.starts_with(['-', '.'])
|
||||
@@ -239,6 +240,9 @@ fn percent_decode(s: &str) -> Option<String> {
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if b == b'%' {
|
||||
let hex = bytes.get(i + 1..i + 3)?;
|
||||
if !hex.iter().all(u8::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
|
||||
@@ -123,3 +123,19 @@ proptest! {
|
||||
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_or_sign_inside_a_percent_escape_is_broken() {
|
||||
assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok());
|
||||
for bad in ["%+1", "%-1", "%1", "%+a"] {
|
||||
assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_must_be_all_digits() {
|
||||
assert!(Endpoint::parse("ws://localhost:80/").is_ok());
|
||||
for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] {
|
||||
assert!(Endpoint::parse(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::time::Duration;
|
||||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
@@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
|
||||
})
|
||||
}
|
||||
|
||||
/// The host sent a message over this app's size limit (the file's welcome, usually): final, and
|
||||
/// not the host's doing.
|
||||
pub(crate) fn too_large(limits: &NetLimits) -> Outcome {
|
||||
Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string()))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -183,9 +189,9 @@ pub(crate) enum Taken {
|
||||
/// in answer must go out on this connection.
|
||||
pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
|
||||
match m {
|
||||
ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => {
|
||||
ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => {
|
||||
if let Ok(mut r) = resume.lock() {
|
||||
*r = Some(Resume { session, client_id, seq });
|
||||
*r = Some(Resume { session, client_id, seq, secret });
|
||||
}
|
||||
// The sync client opens the document (and fills in its name and pages).
|
||||
let peers = peers.clone();
|
||||
|
||||
@@ -12,7 +12,9 @@ pub struct NetLimits {
|
||||
/// before: its hello.
|
||||
pub max_client_message: usize,
|
||||
pub max_hello: usize,
|
||||
/// Largest message a client accepts from the host (a welcome carries the whole document).
|
||||
/// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB,
|
||||
/// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server
|
||||
/// (any link can name one) can't make the app or a browser tab buffer more than that.
|
||||
pub max_server_message: usize,
|
||||
/// Messages a client may send per second on average, and in a burst; beyond that it is
|
||||
/// disconnected.
|
||||
@@ -22,6 +24,12 @@ pub struct NetLimits {
|
||||
pub hello_timeout: Duration,
|
||||
/// A connection with no traffic for this long is dead.
|
||||
pub idle_timeout: Duration,
|
||||
/// How long a one-shot request connection (hosting, a library, a plugin directory — and, most
|
||||
/// importantly, publishing a file) waits for the server's reply before giving up. Publishing
|
||||
/// uploads a whole file and a large one on a slow link takes the server a while to receive and
|
||||
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
|
||||
/// easily need several minutes).
|
||||
pub request_reply_timeout: Duration,
|
||||
/// A quiet client pings this often (keeps it under `idle_timeout`).
|
||||
pub keepalive: Duration,
|
||||
/// Messages queued for one client before it counts as stuck and is disconnected.
|
||||
@@ -61,11 +69,12 @@ impl Default for NetLimits {
|
||||
max_connections: 64,
|
||||
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
max_hello: 64 << 10,
|
||||
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
max_server_message: 1 << 30,
|
||||
rate: 100.0,
|
||||
burst: 400.0,
|
||||
hello_timeout: Duration::from_secs(10),
|
||||
idle_timeout: Duration::from_secs(45),
|
||||
request_reply_timeout: Duration::from_secs(300),
|
||||
keepalive: Duration::from_secs(15),
|
||||
queue: 1024,
|
||||
write_timeout: Duration::from_secs(10),
|
||||
@@ -83,3 +92,16 @@ impl Default for NetLimits {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_reply_wait_is_longer_than_idle() {
|
||||
let d = NetLimits::default();
|
||||
// Publishing uploads a whole file; a slow link must not cut it off at the session idle
|
||||
// timeout. Keep the reply wait comfortably above it.
|
||||
assert!(d.request_reply_timeout > d.idle_timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and
|
||||
//! reconnecting. Every test stops its host and closes its sockets.
|
||||
|
||||
use std::io::Read;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
|
||||
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Limits};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
@@ -488,6 +488,21 @@ fn silent_connections_time_out() {
|
||||
host.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connections_without_a_hello_are_told_why_they_end() {
|
||||
// A socket's read timeout can end a moment before the hello's deadline; the connection still
|
||||
// gets the refusal. Twelve at once, so that happens to some of them.
|
||||
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
std::thread::scope(|s| {
|
||||
let waits: Vec<_> = (0..12).map(|_| s.spawn(move || Raw::connect(addr).refusal())).collect();
|
||||
for w in waits {
|
||||
assert_eq!(w.join().unwrap().as_deref(), Some("no hello in time"));
|
||||
}
|
||||
});
|
||||
host.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guests_reconnect_after_a_network_failure() {
|
||||
let (mut host, mut hp, invite) = start();
|
||||
@@ -540,7 +555,9 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
|
||||
let (mut host, mut hp, invite) = start_with(limits, Limits::default());
|
||||
let probe = Probe::new();
|
||||
let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into()));
|
||||
cfg.limits = limits;
|
||||
// The guest pings as often as the host expects. Its own idle timeout stays long: it also bounds
|
||||
// the wait for the welcome, which a loaded machine can take longer than 600 ms to send.
|
||||
cfg.limits.keepalive = limits.keepalive;
|
||||
let _g = Guest::start(cfg, probe.inbox.clone()).unwrap();
|
||||
let mut gp = probe;
|
||||
gp.wait("welcome", welcomed);
|
||||
@@ -642,3 +659,85 @@ fn the_host_removes_someone_and_they_cant_come_back() {
|
||||
host.stop();
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
let mut doc = Document::new("B".repeat(1 << 20));
|
||||
doc.repair();
|
||||
let probe = Probe::new();
|
||||
let cfg = HostConfig {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
core: Limits::default(),
|
||||
};
|
||||
let mut host = Host::start(cfg, probe.inbox.clone()).unwrap();
|
||||
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
|
||||
|
||||
let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into()));
|
||||
guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() };
|
||||
let mut gp = Probe::new();
|
||||
let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap();
|
||||
let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1");
|
||||
gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large)));
|
||||
assert!(!welcomed(&gp.events));
|
||||
host.stop();
|
||||
}
|
||||
|
||||
/// How long a connection that never says hello stays open while `poke` sends it something every
|
||||
/// 300 ms (told whether it's the first time); `None`: still open after 6 s. A thread of its own
|
||||
/// reads the socket and notes when the host ends the stream, so pongs queued ahead of the close
|
||||
/// and a slow poke loop don't delay it.
|
||||
fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option<Duration> {
|
||||
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut r = Raw::connect(addr);
|
||||
let mut watch = r.0.get_ref().try_clone().unwrap();
|
||||
let t0 = Instant::now();
|
||||
let (ended_tx, ended) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 1024];
|
||||
let until = Instant::now() + WAIT;
|
||||
loop {
|
||||
match watch.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) && Instant::now() < until => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let _ = ended_tx.send(Instant::now());
|
||||
});
|
||||
let (mut closed, mut first) = (None, true);
|
||||
while t0.elapsed() < Duration::from_secs(6) {
|
||||
if !poke(&mut r, std::mem::take(&mut first)) {
|
||||
closed = Some(t0.elapsed());
|
||||
break;
|
||||
}
|
||||
if let Ok(at) = ended.recv_timeout(Duration::from_millis(300)) {
|
||||
closed = Some(at.saturating_duration_since(t0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
host.stop();
|
||||
let _ = reader.join();
|
||||
closed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pings_do_not_keep_a_connection_without_a_hello_alive() {
|
||||
let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok());
|
||||
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() {
|
||||
// A binary message begun and never finished: one masked byte per frame, each in time for the
|
||||
// read timeout.
|
||||
let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok());
|
||||
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}");
|
||||
}
|
||||
|
||||
@@ -196,19 +196,27 @@ pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits,
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
|
||||
/// server a while to keep).
|
||||
/// A WebSocket to `server` that waits for answers up to the request-reply timeout (a large upload
|
||||
/// takes the server a while to keep).
|
||||
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
|
||||
let (stream, _, _) = open(server, timeout, limits)?;
|
||||
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
|
||||
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
|
||||
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
|
||||
// A publish uploads a whole file; a large project on a slow link can take several minutes to
|
||||
// receive and answer. The wait uses `request_reply_timeout`, not the session `idle_timeout`.
|
||||
ws.get_ref().set_read_timeout(Some(limits.request_reply_timeout)).map_err(|e| e.to_string())?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost.
|
||||
/// How long `close` waits for the server's close.
|
||||
const CLOSE_DRAIN: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost,
|
||||
/// but no longer than [`CLOSE_DRAIN`] (a server that keeps pinging, or goes quiet, would pin the
|
||||
/// loop).
|
||||
fn close(mut ws: Ws) {
|
||||
let _ = ws.close(None);
|
||||
ws.get_mut().read_until(Some(std::time::Instant::now() + CLOSE_DRAIN));
|
||||
while ws.read().is_ok() {}
|
||||
}
|
||||
|
||||
@@ -240,3 +248,51 @@ fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whether `close` returns within 3 s against a server that never answers the close and does
|
||||
/// `serve` instead, with reads waiting up to `idle`.
|
||||
fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket<std::net::TcpStream>) + Send + 'static) -> bool {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
if let Ok(ws) = tungstenite::accept(s) {
|
||||
serve(ws);
|
||||
}
|
||||
});
|
||||
let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() };
|
||||
let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false };
|
||||
let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
close(ws);
|
||||
let _ = tx.send(());
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(3)).is_ok()
|
||||
}
|
||||
|
||||
/// A server that keeps pinging and never answers the close must not pin `close` forever.
|
||||
#[test]
|
||||
fn close_returns_against_a_server_that_keeps_pinging() {
|
||||
let pinging = closes_in_time(Duration::from_millis(300), |mut ws| {
|
||||
while ws.send(Message::Ping(Bytes::new())).is_ok() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
});
|
||||
assert!(pinging, "close() kept draining a pinging server");
|
||||
}
|
||||
|
||||
/// Nor one that goes quiet, however long the idle timeout.
|
||||
#[test]
|
||||
fn close_returns_against_a_silent_server() {
|
||||
let silent = closes_in_time(Duration::from_secs(10), |ws| {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
drop(ws);
|
||||
});
|
||||
assert!(silent, "close() waited out the idle timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,16 @@ pub struct Duplex {
|
||||
tls: Option<Tls>,
|
||||
/// What the bytes read count against (a server's reader), if anything.
|
||||
meter: Option<Meter>,
|
||||
/// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]).
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
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(), meter: None };
|
||||
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
|
||||
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None };
|
||||
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None };
|
||||
Ok((a, b, stream))
|
||||
}
|
||||
|
||||
@@ -49,6 +51,12 @@ impl Duplex {
|
||||
self.meter = Some(meter);
|
||||
}
|
||||
|
||||
/// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in
|
||||
/// time (a message trickled in can't outlast it). `None`: only the read timeout applies.
|
||||
pub fn read_until(&mut self, deadline: Option<Instant>) {
|
||||
self.deadline = deadline;
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -187,10 +195,25 @@ impl Duplex {
|
||||
|
||||
impl Read for Duplex {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let n = match &self.tls {
|
||||
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
|
||||
None => self.read.read(buf),
|
||||
}?;
|
||||
let n = loop {
|
||||
if let Some(d) = self.deadline {
|
||||
let left = d.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
return Err(io::ErrorKind::TimedOut.into());
|
||||
}
|
||||
self.read.set_read_timeout(Some(left))?;
|
||||
}
|
||||
let read = match &self.tls {
|
||||
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
|
||||
None => self.read.read(buf),
|
||||
};
|
||||
match read {
|
||||
// The socket's timeout can run out a moment before the deadline (it counts whole
|
||||
// milliseconds): wait out the rest, so a read times out only once it has passed.
|
||||
Err(e) if self.deadline.is_some() && matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
|
||||
read => break read?,
|
||||
}
|
||||
};
|
||||
if let Some(m) = self.meter.as_mut() {
|
||||
m.add(n)?;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 {
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// Asserts two files are hosted and joined independently: each has its own port, session and
|
||||
/// edits, presence stays in its file's session, and closing one file's session leaves the
|
||||
/// other live.
|
||||
#[test]
|
||||
fn two_files_are_hosted_and_joined_independently() {
|
||||
let mut host = session("Hana");
|
||||
@@ -123,8 +126,14 @@ fn two_files_are_hosted_and_joined_independently() {
|
||||
host.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(host.live_summary()["link"], a["link"]);
|
||||
assert_eq!(host.live_summary()["document"], 0);
|
||||
// Each joined file opens in front when its welcome arrives, and the two joins race: the file
|
||||
// whose welcome came last is in front.
|
||||
assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front");
|
||||
// Concurrent joins may complete in either order. Put B in front explicitly so the following
|
||||
// assertions exercise A's background session independent of network timing.
|
||||
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap();
|
||||
assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits");
|
||||
// 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);
|
||||
|
||||
@@ -13,6 +13,7 @@ figcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Boards sync: the file kind reaches joiners in their snapshot, and a layer's board metadata (a
|
||||
//! connector's ends, a sticky's author) syncs as one property, through both encodings.
|
||||
|
||||
use figcraft_doc::{BoardItem, Connector, Document, End, FileKind, NodeKind, Routing, Side};
|
||||
|
||||
use crate::change::{Change, DocProp, Prop};
|
||||
use crate::replica::{Mode, Replica};
|
||||
use crate::{diff, message};
|
||||
|
||||
#[test]
|
||||
fn a_boards_kind_and_its_objects_metadata_sync() {
|
||||
let mut doc = Document::new("Board");
|
||||
doc.kind = FileKind::Board;
|
||||
let page = doc.first_page().unwrap();
|
||||
let a = doc.create(page, NodeKind::Frame, "Sticky", 0.0, 0.0, 200.0, 200.0).unwrap();
|
||||
doc.node_mut(a).unwrap().board = Some(BoardItem::Sticky { author: "Ada".into() });
|
||||
let host = Replica::new(doc);
|
||||
// A joiner's copy, built from the snapshot, is a board with the sticky's metadata.
|
||||
let snap = host.snapshot().unwrap();
|
||||
assert!(snap.iter().any(|c| matches!(c, Change::SetDoc { prop: DocProp::Kind, .. })));
|
||||
let joiner = Replica::from_snapshot(&snap).unwrap();
|
||||
assert_eq!(joiner.document().kind, FileKind::Board);
|
||||
assert_eq!(joiner.document().node(a).unwrap().board, host.document().node(a).unwrap().board);
|
||||
// Re-attaching a connector's end is one property write, the same after the wire.
|
||||
let mut edited = host.document().clone();
|
||||
let c = edited.create(page, NodeKind::Vector { network: Default::default() }, "Connector", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
edited.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector {
|
||||
from: End::Node { node: a, side: Side::Auto },
|
||||
to: End::Point { x: 5.0, y: 5.0 },
|
||||
routing: Routing::Elbow,
|
||||
}));
|
||||
let mut r = host.clone();
|
||||
r.apply(&diff(&host, &edited).unwrap().changes, Mode::Strict).unwrap();
|
||||
let mut moved = r.document().clone();
|
||||
moved.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector {
|
||||
from: End::Node { node: a, side: Side::Top },
|
||||
to: End::Point { x: 9.0, y: 1.0 },
|
||||
routing: Routing::Straight,
|
||||
}));
|
||||
let d = diff(&r, &moved).unwrap();
|
||||
assert!(matches!(d.changes.as_slice(), [Change::Set { prop: Prop::Board, .. }]), "{:?}", d.changes);
|
||||
let wire: Vec<Change> = message::decode(&message::encode(&d.changes).unwrap(), usize::MAX).unwrap();
|
||||
r.apply(&wire, Mode::Strict).unwrap();
|
||||
assert_eq!(r.document(), &moved);
|
||||
}
|
||||
@@ -87,8 +87,9 @@ macro_rules! node_props {
|
||||
($($variant:ident => $field:ident),* $(,)?) => {
|
||||
/// A node property: the tree position (`parent`, `position`) or one saved field of
|
||||
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
|
||||
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
|
||||
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
|
||||
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
|
||||
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
|
||||
/// binary form numbers them).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Prop {
|
||||
@@ -180,6 +181,8 @@ node_props! {
|
||||
Annotations => annotations,
|
||||
CodeLinks => code_links,
|
||||
DevResources => dev_resources,
|
||||
Measurements => measurements,
|
||||
Board => board,
|
||||
}
|
||||
|
||||
/// A document-level property. Lists of entries with ids (styles, comments, variable collections,
|
||||
@@ -218,6 +221,12 @@ pub enum DocProp {
|
||||
Variables,
|
||||
/// Variable ids in order.
|
||||
VariableOrder,
|
||||
/// A design file or a board.
|
||||
Kind,
|
||||
/// A board's countdown (`null`: none).
|
||||
Timer,
|
||||
/// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s.
|
||||
Vote,
|
||||
}
|
||||
|
||||
/// A document-level entry, by id.
|
||||
@@ -237,6 +246,8 @@ pub enum Entry {
|
||||
DevSnapshot(NodeId),
|
||||
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
|
||||
Image(String),
|
||||
/// A voter's ballot in a board's vote (value: the layer ids voted on).
|
||||
Ballot(String),
|
||||
}
|
||||
|
||||
/// One synced value a change writes: a node property, a document-level property or a
|
||||
@@ -310,7 +321,7 @@ impl Entry {
|
||||
Entry::Variable(id) => Some(id_space(id, VARIABLE_ID_PREFIX)),
|
||||
Entry::Collection(id) => Some(id_space(id, COLLECTION_ID_PREFIX)),
|
||||
Entry::Comment(id) => Some(space_of(*id)),
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) => None,
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +334,7 @@ impl Entry {
|
||||
Entry::Variable(id) => format!("variable {id}"),
|
||||
Entry::DevSnapshot(id) => format!("ready-for-dev snapshot of {id}"),
|
||||
Entry::Image(hash) => format!("image {hash}"),
|
||||
Entry::Ballot(voter) => format!("ballot of {voter}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +345,7 @@ impl Entry {
|
||||
Entry::Comment(_) => Some(Family::Comments),
|
||||
Entry::Collection(_) => Some(Family::Collections),
|
||||
Entry::Variable(_) => Some(Family::Variables),
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) => None,
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct Client {
|
||||
/// The session joined (see [`ServerMsg::Welcome`]).
|
||||
session: u64,
|
||||
id: u32,
|
||||
/// What this client resumes its id with (see [`Resume::secret`]).
|
||||
secret: u64,
|
||||
/// The server's state as of `seq`.
|
||||
confirmed: Replica,
|
||||
seq: u64,
|
||||
@@ -84,7 +86,7 @@ impl Client {
|
||||
|
||||
/// Start from the server's `Welcome` (which must carry a snapshot).
|
||||
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
|
||||
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access, secret } = welcome else {
|
||||
return Err(Error::Protocol("expected a welcome with the document".into()));
|
||||
};
|
||||
let mut confirmed = Replica::from_snapshot(&snapshot)?;
|
||||
@@ -93,6 +95,7 @@ impl Client {
|
||||
Ok(Client {
|
||||
session,
|
||||
id: client_id,
|
||||
secret,
|
||||
view: confirmed.clone(),
|
||||
confirmed,
|
||||
seq,
|
||||
@@ -415,9 +418,10 @@ impl Client {
|
||||
ClientMsg::Hello(Hello { resume: Some(self.resume()), ..hello })
|
||||
}
|
||||
|
||||
/// What a reconnect resumes with: this client's session, id and last sequence number.
|
||||
/// What a reconnect resumes with: this client's session, id, last sequence number and the
|
||||
/// secret its id was given with.
|
||||
pub fn resume(&self) -> Resume {
|
||||
Resume { session: self.session, client_id: self.id, seq: self.seq }
|
||||
Resume { session: self.session, client_id: self.id, seq: self.seq, secret: self.secret }
|
||||
}
|
||||
|
||||
/// Handle a message from the server. Returns messages to send: batches that were waiting for
|
||||
@@ -458,10 +462,11 @@ impl Client {
|
||||
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 } => {
|
||||
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret } => {
|
||||
if session != self.session || client_id != self.id {
|
||||
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access });
|
||||
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret });
|
||||
}
|
||||
self.secret = secret;
|
||||
self.access = access;
|
||||
self.rejoin(seq, catch_up, &processed, peers, recompute)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ impl 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 hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ fn only_local_session_links_have_a_code() {
|
||||
"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?host=10.0.0.1:47900&file=local&token=local-+0000000001",
|
||||
"figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a",
|
||||
"ws://192.168.1.20:47900/?file=local&token=local-0123456789a",
|
||||
"",
|
||||
|
||||
@@ -11,6 +11,16 @@ use crate::message::{
|
||||
use crate::sim::Rng;
|
||||
use crate::{Error, Replica, diff};
|
||||
|
||||
#[test]
|
||||
fn encoding_into_a_prefix_keeps_the_wire_body() {
|
||||
let msg = moved_layer();
|
||||
let prefix = b"container header".to_vec();
|
||||
let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap();
|
||||
assert_eq!(&bytes[..prefix.len()], prefix);
|
||||
assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap());
|
||||
assert_eq!(decode::<ClientMsg>(&bytes[prefix.len()..], bytes.len()).unwrap(), msg);
|
||||
}
|
||||
|
||||
/// A typical edit: one layer dragged (its transform).
|
||||
fn moved_layer() -> ClientMsg {
|
||||
let mut doc = Document::new("f");
|
||||
@@ -38,7 +48,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
name: "Ada".into(),
|
||||
color: "#FF8800".into(),
|
||||
client_version: "0.2.0".into(),
|
||||
resume: Some(Resume { session: 77, client_id: 4, seq: 10 }),
|
||||
resume: Some(Resume { session: 77, client_id: 4, seq: 10, secret: 0x5EC2E7 }),
|
||||
}),
|
||||
moved_layer(),
|
||||
cursor(),
|
||||
@@ -102,6 +112,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
processed: Processed::default(),
|
||||
peers: vec![peer.clone()],
|
||||
access: Access::Edit,
|
||||
secret: 0x5EC2E7,
|
||||
},
|
||||
ServerMsg::Welcome {
|
||||
session: 77,
|
||||
@@ -111,6 +122,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
processed: Processed { last_batch: 7, refused: vec![3, 6] },
|
||||
peers: vec![],
|
||||
access: Access::View,
|
||||
secret: 1,
|
||||
},
|
||||
ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }),
|
||||
ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] },
|
||||
|
||||
@@ -132,18 +132,15 @@ impl<T: Keyed> List<T> {
|
||||
let keys: Vec<T::Key> = value.read("order")?;
|
||||
let list = (self.get_mut)(doc);
|
||||
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
|
||||
let mut at: BTreeMap<T::Key, Vec<usize>> = BTreeMap::new();
|
||||
let mut at: BTreeMap<T::Key, std::collections::VecDeque<usize>> = BTreeMap::new();
|
||||
for (i, e) in slots.iter().enumerate() {
|
||||
if let Some(e) = e {
|
||||
at.entry(e.key()).or_default().push(i);
|
||||
at.entry(e.key()).or_default().push_back(i);
|
||||
}
|
||||
}
|
||||
for k in keys {
|
||||
let Some(idx) = at.get_mut(&k) else { continue };
|
||||
if idx.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let i = idx.remove(0);
|
||||
let Some(i) = idx.pop_front() else { continue };
|
||||
if let Some(e) = slots.get_mut(i).and_then(Option::take) {
|
||||
list.push(e);
|
||||
}
|
||||
@@ -213,6 +210,9 @@ doc_scalars! {
|
||||
ImagePresets => image_presets,
|
||||
AnnotationCategories => annotation_categories,
|
||||
Libraries => libraries,
|
||||
Kind => kind,
|
||||
Timer => timer,
|
||||
Vote => vote,
|
||||
}
|
||||
|
||||
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
|
||||
@@ -252,7 +252,8 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
|
||||
|k| Entry::Image(k.clone()),
|
||||
image_json,
|
||||
out,
|
||||
)
|
||||
)?;
|
||||
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
|
||||
}
|
||||
|
||||
/// Apply a document-level change. Returns whether it can change which pages are listed (the
|
||||
@@ -288,6 +289,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
let img = image_of(value)?;
|
||||
doc.images.insert(k.clone(), img);
|
||||
}
|
||||
Entry::Ballot(k) => {
|
||||
doc.votes.insert(k.clone(), value.read("ballot")?);
|
||||
}
|
||||
},
|
||||
Change::Remove { entry } => match entry {
|
||||
Entry::Style(k) => STYLES.remove(doc, k),
|
||||
@@ -300,6 +304,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
Entry::Image(k) => {
|
||||
doc.images.remove(k);
|
||||
}
|
||||
Entry::Ballot(k) => {
|
||||
doc.votes.remove(k);
|
||||
}
|
||||
},
|
||||
_ => return Err(Error::Invalid("not a document-level change".into())),
|
||||
}
|
||||
@@ -336,6 +343,7 @@ pub(crate) fn entry_value(doc: &Document, entry: &Entry) -> Result<Option<Json>,
|
||||
Entry::Variable(k) => doc.variables.variables.iter().find(|v| v.id == *k).map(Json::of),
|
||||
Entry::DevSnapshot(k) => doc.dev_snapshots.get(k).map(|s| Json::of(s.as_ref())),
|
||||
Entry::Image(k) => doc.images.get(k).map(image_json),
|
||||
Entry::Ballot(k) => doc.votes.get(k).map(Json::of),
|
||||
};
|
||||
value.transpose()
|
||||
}
|
||||
@@ -349,6 +357,7 @@ pub(crate) fn has(doc: &Document, entry: &Entry) -> bool {
|
||||
Entry::Variable(k) => doc.variables.variables.iter().any(|v| v.id == *k),
|
||||
Entry::DevSnapshot(k) => doc.dev_snapshots.contains_key(k),
|
||||
Entry::Image(k) => doc.images.contains_key(k),
|
||||
Entry::Ballot(k) => doc.votes.contains_key(k),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user