Merge main into #1389

# Conflicts:
#	crates/engine/src/cmd/file.rs
#	docs/file-format-parity.md
This commit is contained in:
bflatastic
2026-10-29 12:08:39 +07:00
579 changed files with 58521 additions and 39795 deletions
+3
View File
@@ -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"
+24
View File
@@ -23,3 +23,27 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+4 -2
View File
@@ -26,8 +26,10 @@ permissions:
jobs:
windows-arm64:
# 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/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: figcraft
app-dir: FigCraft
install-relative-path: Programs/ArtCraft/FigCraft
install-scope: perUser
+2
View File
@@ -8,3 +8,5 @@ plans/
/log/
.DS_Store
*.profraw
.cache/
.vscode/
+6
View File
@@ -40,3 +40,9 @@ egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own l
| 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. |
## Arabic interface catalog
| Path | Contributor | Source | Licence | Review |
|---|---|---|---|---|
| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending |
+1
View File
@@ -62,6 +62,7 @@ code.
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
safety nets, not substitutes for `Result`.
Generated
+378 -52
View File
@@ -253,7 +253,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -855,7 +855,20 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types",
"core-graphics-types 0.1.3",
"foreign-types",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-graphics-types 0.2.0",
"foreign-types",
"libc",
]
@@ -871,6 +884,28 @@ dependencies = [
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"libc",
]
[[package]]
name = "core-text"
version = "22.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7"
dependencies = [
"core-foundation 0.10.1",
"core-graphics 0.25.0",
"foreign-types",
]
[[package]]
name = "core_detect"
version = "1.0.0"
@@ -907,12 +942,94 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"fixed_decimal",
"icu_decimal",
"icu_locale_core",
"icu_locale_fallback",
"icu_normalizer",
"icu_plurals",
"icu_properties",
"icu_provider",
"icu_provider_adapters",
"icu_provider_blob",
"log",
"objc2-foundation 0.3.2",
"sys-locale",
"unicode-bidi",
"windows-registry",
"writeable",
]
[[package]]
name = "craft-keymap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"egui",
"serde",
"serde_json",
]
[[package]]
name = "craft-menu"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-keymap",
"craft-quit",
"egui",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-quit"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"dispatch2",
"egui",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-fonts-build",
"craft-i18n",
"egui",
"epaint",
"js-sys",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"skrifa 0.44.0",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"chrono",
"dunce",
@@ -937,7 +1054,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"wayland-client",
]
@@ -951,6 +1068,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -1078,6 +1204,12 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "digest"
version = "0.10.7"
@@ -1437,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1504,9 +1636,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"arboard",
"craft-menu",
"craft-update",
"craft-window",
"eframe",
@@ -1532,7 +1665,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1546,7 +1679,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1557,12 +1690,13 @@ dependencies = [
"postcard",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.21",
]
[[package]]
name = "figcraft-collab-net"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1585,7 +1719,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"kurbo",
"serde",
@@ -1594,7 +1728,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1606,10 +1740,11 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"deunicode",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1622,6 +1757,7 @@ dependencies = [
"figcraft-render",
"figcraft-svg",
"figcraft-text",
"figcraft-vectordrawable",
"image",
"log",
"regex",
@@ -1636,7 +1772,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1645,13 +1781,14 @@ dependencies = [
"ruzstd",
"serde",
"serde_json",
"sha1",
"sha2",
"zip 2.6.1",
]
[[package]]
name = "figcraft-geom"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"kurbo",
"serde",
@@ -1659,7 +1796,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1667,7 +1804,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1683,7 +1820,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1692,7 +1829,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1709,7 +1846,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"figcraft-color",
"serde",
@@ -1722,7 +1859,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1740,7 +1877,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1764,7 +1901,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1780,12 +1917,14 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"core-text",
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
"log",
"objc2 0.6.5",
"png",
"skrifa 0.47.0",
"unicode-bidi",
@@ -1794,10 +1933,11 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"base64 0.22.1",
"chrono",
"craft-ui",
"craft-update",
"egui",
"egui_extras",
@@ -1820,9 +1960,24 @@ dependencies = [
]
[[package]]
name = "figcraft-web"
version = "0.11.2"
name = "figcraft-vectordrawable"
version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"roxmltree 0.21.2",
"thiserror 2.0.21",
]
[[package]]
name = "figcraft-web"
version = "0.12.1"
dependencies = [
"base64 0.22.1",
"eframe",
"egui",
"figcraft-collab-net",
@@ -1830,6 +1985,7 @@ dependencies = [
"figcraft-ui-egui",
"js-sys",
"log",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -1851,6 +2007,17 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "fixed_decimal"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
dependencies = [
"displaydoc",
"smallvec",
"writeable",
]
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -2337,9 +2504,9 @@ dependencies = [
[[package]]
name = "icu_collections"
version = "2.4.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
@@ -2350,43 +2517,110 @@ dependencies = [
]
[[package]]
name = "icu_locale_core"
version = "2.4.0"
name = "icu_decimal"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
dependencies = [
"displaydoc",
"fixed_decimal",
"icu_locale_core",
"icu_pattern",
"icu_plurals",
"icu_provider",
"serde",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.4.0"
name = "icu_locale_fallback"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
dependencies = [
"icu_locale_core",
"icu_locale_fallback_data",
"icu_provider",
"potential_utf",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locale_fallback_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"utf16_iter",
"utf8_iter",
"write16",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.4.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_pattern"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
dependencies = [
"displaydoc",
"either",
"serde",
"writeable",
"zerovec",
]
[[package]]
name = "icu_plurals"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
dependencies = [
"displaydoc",
"fixed_decimal",
"icu_provider",
"serde",
"zerovec",
]
[[package]]
name = "icu_properties"
version = "2.4.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
@@ -2399,18 +2633,21 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.4.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.4.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"postcard",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -2418,6 +2655,30 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_provider_adapters"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
dependencies = [
"icu_locale_fallback",
"icu_provider",
]
[[package]]
name = "icu_provider_blob"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
dependencies = [
"icu_provider",
"postcard",
"serde",
"writeable",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
@@ -2649,6 +2910,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -2669,7 +2940,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -2782,6 +3053,9 @@ name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
dependencies = [
"serde_core",
]
[[package]]
name = "litrs"
@@ -2858,6 +3132,24 @@ dependencies = [
"pxfm",
]
[[package]]
name = "muda"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
name = "naga"
version = "30.0.1"
@@ -3356,7 +3648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3622,6 +3914,8 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
@@ -4028,7 +4322,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4085,7 +4379,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4619,6 +4913,18 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"js-sys",
"libc",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "tar"
version = "0.4.46"
@@ -4636,10 +4942,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4793,6 +5099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -5103,6 +5410,12 @@ dependencies = [
"xmlwriter",
]
[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8-zero"
version = "0.8.1"
@@ -5752,7 +6065,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5982,7 +6295,7 @@ dependencies = [
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
"core-graphics",
"core-graphics 0.23.2",
"cursor-icon",
"dpi",
"js-sys",
@@ -6090,11 +6403,20 @@ dependencies = [
"read-fonts 0.39.2",
]
[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
dependencies = [
"either",
]
[[package]]
name = "x11-dl"
@@ -6173,7 +6495,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.11.2"
version = "0.12.1"
dependencies = [
"figcraft-engine",
"serde_json",
@@ -6362,8 +6684,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"litemap",
"serde_core",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
@@ -6372,6 +6697,7 @@ version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
+19 -6
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.11.2"
version = "0.12.1"
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" }
@@ -34,13 +35,18 @@ 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" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# 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"] }
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -68,11 +74,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"
@@ -95,6 +105,9 @@ webbrowser = "1"
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
# Style lints from newer toolchains that fight index-heavy pixel code.
needless_range_loop = "allow"
@@ -123,7 +136,7 @@ lto = "thin"
codegen-units = 4
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
@@ -155,4 +168,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457
# 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" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+1 -1
View File
@@ -208,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) 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) 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>
+17 -5
View File
@@ -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-25 · **Change:** minor (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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); 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
@@ -63,7 +63,7 @@ native speakers' reviews of the nine 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; 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) |
| 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); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.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) |
@@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people.
| 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 |
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
@@ -114,7 +114,7 @@ native speakers' reviews of the nine language drafts need people.
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, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic 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,
@@ -127,11 +127,16 @@ 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 (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)).
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 and Vietnamese; Arabic native review and panel mirroring ([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-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
- **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)).
@@ -347,6 +352,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
| 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) |
@@ -359,6 +370,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 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 |
+9
View File
@@ -91,6 +91,15 @@ impl Args {
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.value("scale") {
None => Ok(1.0),
+12 -5
View File
@@ -4,7 +4,7 @@ use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
@@ -25,7 +25,7 @@ fn ext(path: &str) -> String {
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,
}
}
@@ -225,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> {
@@ -239,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 {
+11 -5
View File
@@ -17,8 +17,8 @@ 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>]
@@ -144,12 +144,18 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
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()? };
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" => {
+2
View File
@@ -10,3 +10,5 @@ mod fonts;
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;
@@ -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);
}
+11 -6
View File
@@ -64,6 +64,16 @@ fn is_code(shown: &str) -> bool {
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
}
/// The links file keeps hashes only, never the codes or the token they wrap.
fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
assert!(!links.contains(secret), "{links}");
}
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
assert_eq!(links.matches("\"code\"").count(), 2);
}
#[test]
fn a_file_shared_online_is_joined_by_its_codes() {
let dir = TempDir::new("codes-share");
@@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
let before_stop = ids(dir.path());
assert_eq!(before_stop.len(), 4);
// On disk: hashes only, of the codes as of the links' tokens.
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
assert!(!links.contains(secret), "{links}");
}
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
assert_eq!(links.matches("\"code\"").count(), 2);
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
// Someone types the edit code, the way people type.
let mut b = app("Bo", &url);
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
+31 -27
View File
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
}
}
/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
let mut raw = Raw::connect(addr);
raw.send(&open(edit));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
let mut rng = Rng::new(5);
for round in 0..12 {
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
for _ in 0..1 + rng.below(8) {
let i = rng.below(junk.len());
junk[i] = rng.next_u64() as u8;
}
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
}
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
let mut raw = Raw::connect(addr);
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
}
#[test]
fn publish_enable_update_and_usage() {
let dir = TempDir::new("library");
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
let edit = Invite::parse(&p.edit).unwrap();
let mut raw = Raw::connect(addr);
raw.send(&open(&edit));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
let mut rng = Rng::new(5);
for round in 0..12 {
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
for _ in 0..1 + rng.below(8) {
let i = rng.below(junk.len());
junk[i] = rng.next_u64() as u8;
}
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
}
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
raw.send(&ClientMsg::Library(LibraryReq::Usage));
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
let mut raw = Raw::connect(addr);
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
junk_is_refused(addr, &edit);
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
// Kept on disk: the library is there after the server stops.
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
use super::journal::flush_log;
use super::{
Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply,
LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
};
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
}
}
+304
View File
@@ -0,0 +1,304 @@
use super::{
Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
};
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) = std::fs::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(())
}
}
+273
View File
@@ -0,0 +1,273 @@
use super::{
FORCED_COMPACTION_EVERY, Instant, LOG, LOG_HEADER, Log, MAX_PROBLEMS, META, OpenOptions, RewriteError, SNAPSHOT, Server, Store, Write, disk, fs,
now, write_json,
};
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(())
}
}
}
+165
View File
@@ -0,0 +1,165 @@
use super::{
Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
write_json,
};
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) }
}
+157
View File
@@ -0,0 +1,157 @@
use super::{
Access, Action, CODE_GONE, CODES_BUSY, Entry, FileInfo, Instant, IpAddr, LINKS, LinkInfo, MAX_GONE_CODES, NO_CODE, NewLink, OpenFile, Sealed,
Store, clean_name, codes, fs, hash, new_link, now, same_secret, write_json,
};
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(())
}
}
+2
View File
@@ -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", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+172
View File
@@ -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(&params)))
.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(&params)}))
})
.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(&params))).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(())
}
+6 -1
View File
@@ -13,17 +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;
+8 -1
View File
@@ -1,4 +1,4 @@
//! The page's URL: the invite link its fragment names.
//! 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
@@ -13,3 +13,10 @@ pub fn join_link(fragment: &str) -> Option<&str> {
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"))
}
+13 -2
View File
@@ -1,6 +1,6 @@
//! `#join=` in the page's URL.
//! `#join=` and `?control` in the page's URL.
use crate::query::join_link;
use crate::query::{control, join_link};
#[test]
fn join_takes_the_rest_of_the_fragment() {
@@ -27,3 +27,14 @@ fn an_overlong_link_is_ignored() {
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");
}
+70
View File
@@ -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"[..]));
}
}
+12
View File
@@ -47,6 +47,13 @@ pub fn start() {
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 }))
}),
)
@@ -79,7 +86,9 @@ impl eframe::App for WebShell {
}
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();
}
}
@@ -109,6 +118,9 @@ impl Services for WebServices {
}
/// 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) => {
+4
View File
@@ -48,6 +48,10 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
# same guarded path as the close button); winit's default Quit ends the process through
# `terminate:`, losing unsaved changes without a word (issue #857).
craft-menu = { workspace = true }
[dev-dependencies]
wat = { workspace = true }
+39 -5
View File
@@ -6,6 +6,7 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_engine::events::{self, EventLog};
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
@@ -58,7 +59,7 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.extend(queue.drain(..count));
}
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
pub fn start(port: u16, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>) {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -71,7 +72,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
spawn_connection(stream, queue.clone(), ctx.clone(), events.clone(), &mut workers, TIMEOUT);
}
});
if let Err(e) = spawned {
@@ -84,13 +85,20 @@ fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: imp
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) {
fn spawn_connection(
stream: TcpStream,
queue: Queue,
ctx: CtxSlot,
events: Arc<EventLog>,
workers: &mut Vec<std::thread::JoinHandle<()>>,
timeout: Duration,
) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
return;
}
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
let _ = serve(stream, queue, ctx, timeout);
let _ = serve_with_events(stream, queue, ctx, events, timeout);
}) {
Ok(worker) => workers.push(worker),
Err(e) => log::error!("control connection thread: {e}"),
@@ -100,7 +108,13 @@ 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<()> {
/// Serve a connection with a private event log (the tests; the app shares its session's log).
#[cfg(test)]
fn serve(out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
serve_with_events(out, queue, ctx, Arc::new(EventLog::default()), timeout)
}
fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
let mut reader = BufReader::new(out.try_clone()?);
@@ -126,6 +140,16 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let id = v.get("id").cloned().unwrap_or(Value::Null);
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
// A poll waits on the shared log, not on a frame: it needs no UI thread and no
// request slot, and it works while the window is minimised.
if method == "events.poll" {
let reply = match event_poll(&events, &params) {
Ok(result) => json!({"id": id, "ok": true, "result": result}),
Err(error) => json!({"id": id, "ok": false, "error": error}),
};
writeln!(out, "{reply}")?;
continue;
}
let (tx, rx) = std::sync::mpsc::channel();
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -166,6 +190,16 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
Ok(())
}
/// `events.poll {after?, timeoutMs?, topics?, limit?}`: read the shared log, waiting up to 25 s.
fn event_poll(log: &EventLog, params: &Value) -> Result<Value, String> {
let after = params.get("after").and_then(Value::as_u64).unwrap_or(0);
let timeout = params.get("timeoutMs").and_then(Value::as_u64).unwrap_or(20_000).min(25_000);
let limit = params.get("limit").and_then(Value::as_u64).unwrap_or(100).clamp(1, 1000) as usize;
let topics = events::topics(params.get("topics"))?;
let result = log.poll(after, &topics, limit, Duration::from_millis(timeout));
serde_json::to_value(result).map_err(|e| format!("serialize events: {e}"))
}
#[cfg(test)]
#[path = "control_server_tests.rs"]
mod tests;
+87 -3
View File
@@ -198,12 +198,26 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let mut clients = Vec::new();
for _ in 0..MAX_CONNECTIONS {
let (client, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
spawn_connection(
server,
pending.clone(),
ctx.clone(),
Arc::new(figcraft_engine::events::EventLog::default()),
&mut workers,
Duration::from_secs(5),
);
clients.push(client);
}
assert_eq!(workers.len(), MAX_CONNECTIONS);
let (mut extra, server) = pair();
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
spawn_connection(
server,
pending.clone(),
ctx.clone(),
Arc::new(figcraft_engine::events::EventLog::default()),
&mut workers,
Duration::from_secs(5),
);
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
assert_eq!(workers.len(), MAX_CONNECTIONS);
for client in clients {
@@ -215,7 +229,7 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
std::thread::sleep(Duration::from_millis(1));
}
let (mut client, server) = pair();
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
spawn_connection(server, pending, ctx, Arc::new(figcraft_engine::events::EventLog::default()), &mut workers, Duration::from_secs(5));
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
@@ -249,6 +263,39 @@ fn a_client_that_stops_reading_cannot_hold_a_worker() {
worker.join().unwrap().unwrap();
}
#[test]
fn events_poll_is_answered_without_the_ui_queue() {
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let log = Arc::new(figcraft_engine::events::EventLog::default());
log.push("comment.added", "Doc", None, json!({"commentId": 7}));
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
client.write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":0,\"timeoutMs\":0}}\n").unwrap();
let mut reader = BufReader::new(client);
let r = reply(&mut reader);
assert_eq!(r["id"], 1);
assert_eq!(r["result"]["events"].as_array().unwrap().len(), 1);
assert_eq!(r["result"]["events"][0]["kind"], "comment.added");
assert_eq!(r["result"]["events"][0]["data"]["commentId"], 7);
assert_eq!(r["result"]["cursor"], 1);
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn a_comment_added_in_the_session_is_pollable() {
let mut session = figcraft_engine::Session::new();
let log = session.events.clone();
let id = session.execute("comment.add", &json!({"x": 10, "y": 20, "text": "Fix the padding"})).unwrap()["id"].clone();
let r = event_poll(&log, &json!({"after": 0, "timeoutMs": 0})).unwrap();
assert_eq!(r["events"].as_array().unwrap().len(), 1);
assert_eq!(r["events"][0]["kind"], "comment.added");
assert_eq!(r["events"][0]["data"]["commentId"], id);
}
#[test]
fn a_limit_sized_frame_is_accepted_at_eof() {
let (mut client, server) = pair();
@@ -342,3 +389,40 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
worker.join().unwrap().unwrap();
}
}
#[test]
fn events_poll_rejects_an_unknown_topic_without_the_ui_queue() {
let (client, server) = pair();
let pending = queue();
let q = pending.clone();
let log = Arc::new(figcraft_engine::events::EventLog::default());
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
let mut reader = BufReader::new(client);
reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"topics\":[\"nope\"],\"timeoutMs\":0}}\n").unwrap();
let r = reply(&mut reader);
assert_eq!(r["id"], 1);
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().is_some_and(|e| e.contains("unknown topic")), "{r}");
assert!(pending.lock().unwrap().is_empty());
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
#[test]
fn events_poll_reports_reset_for_a_cursor_ahead_of_the_log() {
let (client, server) = pair();
let log = Arc::new(figcraft_engine::events::EventLog::default());
log.push("comment.added", "Doc", None, json!({"commentId": 1}));
let q = queue();
let l = log.clone();
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
let mut reader = BufReader::new(client);
reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":2,\"timeoutMs\":0}}\n").unwrap();
let r = reply(&mut reader);
assert_eq!(r["result"]["reset"], true, "{r}");
assert!(r["result"]["events"].as_array().unwrap().is_empty(), "{r}");
assert_eq!(r["result"]["cursor"], 1);
reader.get_ref().shutdown(Shutdown::Write).unwrap();
worker.join().unwrap().unwrap();
}
+10 -1
View File
@@ -234,7 +234,7 @@ fn main() -> eframe::Result {
}
let queue: Arc<Mutex<Vec<ControlRequest>>> = Arc::new(Mutex::new(Vec::new()));
if let Some(p) = port {
control_server::start(p, queue.clone(), ctx_slot.clone());
control_server::start(p, queue.clone(), ctx_slot.clone(), fig.session.events.clone());
}
let icon = services::window_icon();
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
@@ -271,6 +271,15 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
// winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the
// app, so unsaved changes are lost with no warning (issue #857). This menu's Quit
// sends the root window a close request instead, and the unsaved-changes question
// asks just as it does for the close button. Failure keeps winit's menu: the app
// runs, but ⌘Q ends it without asking.
#[cfg(target_os = "macos")]
if let Err(e) = craft_menu::mac::install_app_menu(&cc.egui_ctx, "FigCraft") {
log::warn!("app menu: kept winit's Quit, which ends the app without asking: {e}");
}
Ok(Box::new(App {
queue,
ctx_slot,
+4
View File
@@ -4,3 +4,7 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+6
View File
@@ -86,3 +86,9 @@ sync_github_name = true
[people.leandrodslv]
sync_github_name = true
[people.mateusvillain]
sync_github_name = true
[people.culiulemon]
sync_github_name = true
+23 -18
View File
@@ -2,9 +2,10 @@
//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's
//! threads do — hello on open (resuming as the same client after a drop), presence held until the
//! welcome and changes made for an earlier connection dropped (the sync client sends again what
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping every
//! keep-alive interval, a connection that goes silent counted as dropped, and reconnects with
//! growing pauses after a live connection drops. The browser layer (`web.rs`) feeds it the
//! WebSocket's events and wakes it at [`Driver::deadline`].
//!
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
//! connection of its own, then joins that file as with a link; a server that doesn't know the
@@ -36,13 +37,15 @@ struct Conn {
opened: bool,
welcomed: bool,
seen: Seen,
last_sent: Instant,
/// The last ping (or the connection's start): one goes out every keep-alive interval, whatever
/// else is sent, since presence gets no answer and only what the host sends counts as heard.
last_ping: Instant,
last_heard: Instant,
}
impl Conn {
fn new(now: Instant) -> Self {
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now }
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now }
}
}
@@ -106,7 +109,7 @@ impl<S: Socket> Driver<S> {
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
None => hello(&self.cfg, &self.resume),
};
if !self.write(&hello, now) {
if !self.write(&hello) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -173,7 +176,7 @@ impl<S: Socket> Driver<S> {
return;
}
if let Some(p) = self.throttle.offer(p, now)
&& !self.write(&ClientMsg::Presence(p), now)
&& !self.write(&ClientMsg::Presence(p))
{
self.end(Outcome::Retry("the connection dropped".into()), now);
}
@@ -186,7 +189,7 @@ impl<S: Socket> Driver<S> {
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
return;
}
if !self.write(&msg, now) {
if !self.write(&msg) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -204,7 +207,7 @@ impl<S: Socket> Driver<S> {
let l = &self.cfg.limits;
let conn = self.conn.as_ref().map(|c| {
if c.opened {
[Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
[Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
} else {
[Some(c.started + self.cfg.connect_timeout), None, None]
}
@@ -223,7 +226,7 @@ impl<S: Socket> Driver<S> {
}
let l = self.cfg.limits;
let Some(c) = self.conn.as_ref() else { return };
let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
if !opened {
if now >= started + self.cfg.connect_timeout {
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
@@ -233,21 +236,23 @@ impl<S: Socket> Driver<S> {
if now >= last_heard + l.idle_timeout {
return self.end(Outcome::Retry("the host stopped answering".into()), now);
}
let ok = match self.throttle.take_due(now) {
Some(p) => self.write(&ClientMsg::Presence(p), now),
None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now),
None => true,
};
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
if !ok {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
/// Send one message; false when it couldn't go.
fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool {
/// Send a ping; false when it couldn't go.
fn ping(&mut self, now: Instant) -> bool {
if let Some(c) = self.conn.as_mut() {
c.last_sent = now;
c.last_ping = now;
}
self.write(&ClientMsg::Ping)
}
/// Send one message; false when it couldn't go.
fn write(&mut self, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| self.sock.send(&b))
}
+34 -2
View File
@@ -142,9 +142,10 @@ fn presence_is_throttled_and_quiet_connections_ping() {
d.tick(due);
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0))));
let n = d.sock.sent.len();
// Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic).
// A ping every keep-alive interval from the connection's start, presence or not (the host
// answers, which counts as traffic).
let ping_at = d.deadline().unwrap();
assert_eq!(ping_at, due + Duration::from_secs(15));
assert_eq!(ping_at, t0 + Duration::from_secs(15));
d.tick(ping_at - ms(1));
assert_eq!(d.sock.sent.len(), n, "not yet");
d.message(&encode(&ServerMsg::Pong), ping_at - ms(1));
@@ -152,6 +153,37 @@ fn presence_is_throttled_and_quiet_connections_ping() {
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping));
}
/// A viewer moving the pointer sends presence all the time, which the host doesn't answer: the
/// pings still go out, and the host's pongs keep the connection (before, each presence put the
/// next ping off, nothing was heard for the idle timeout, and the client dropped the connection).
#[test]
fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() {
let t0 = Instant::now();
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
inbox.drain();
let mut answered = 0;
for i in 1..=900u32 {
let now = t0 + ms(100 * u64::from(i));
d.presence(cursor(f64::from(i)), now);
while let Some(due) = d.deadline().filter(|t| *t <= now) {
d.tick(due);
}
// The host answers every ping (and nothing else) at once.
let pinged = d.sock.sent[answered..].contains(&ClientMsg::Ping);
answered = d.sock.sent.len();
if pinged {
d.message(&encode(&ServerMsg::Pong), now);
}
}
assert!(!d.done());
assert_eq!(d.sock.closes, 0, "90 s of presence and pongs: still connected");
assert!(statuses(&inbox.drain()).is_empty());
let pings = d.sock.sent.iter().filter(|m| **m == ClientMsg::Ping).count();
assert!(pings >= 5, "a ping every 15 s ({pings})");
}
#[test]
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
let t0 = Instant::now();
+15 -12
View File
@@ -1,7 +1,8 @@
//! Joining a session: the client end of the transport, with reconnect.
//!
//! A supervisor thread connects, says hello, then writes (presence throttled to about 30 a
//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
//! second, latest wins; a ping every keep-alive interval) while a reader thread takes in what the
//! host sends.
//! When the connection drops after having been live, it reconnects with growing pauses; a
//! refusal or the host ending the session is final.
//!
@@ -280,13 +281,15 @@ fn session(
}
}
};
let mut last_sent = Instant::now();
// A ping every keep-alive interval, whatever else is sent: presence gets no answer, and the
// reader counts only what the host sends.
let mut last_ping = Instant::now();
let hello = hello(cfg, resume);
let mut welcomed = false;
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
let outcome = if send(&mut writer, &hello, &mut last_sent) {
let outcome = if send(&mut writer, &hello) {
loop {
let ping_at = last_sent + cfg.limits.keepalive;
let ping_at = last_ping + cfg.limits.keepalive;
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
let mut out = None;
let mut now_send = Vec::new();
@@ -312,16 +315,17 @@ fn session(
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
Err(RecvTimeoutError::Timeout) => {}
}
if !now_send.iter().all(|m| send(&mut writer, m, &mut last_sent)) {
if !now_send.iter().all(|m| send(&mut writer, m)) {
break Outcome::Retry("the connection dropped".into());
}
let now = Instant::now();
let out = out.or_else(|| throttle.take_due(now));
let ok = match out {
Some(p) => send(&mut writer, &ClientMsg::Presence(p), &mut last_sent),
None if now >= last_sent + cfg.limits.keepalive => send(&mut writer, &ClientMsg::Ping, &mut last_sent),
None => true,
};
let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p)));
let ok = ok
&& (now < last_ping + cfg.limits.keepalive || {
last_ping = now;
send(&mut writer, &ClientMsg::Ping)
});
if !ok {
break Outcome::Retry("the connection dropped".into());
}
@@ -335,8 +339,7 @@ fn session(
}
/// Send one message; false when the connection is gone.
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg, last_sent: &mut Instant) -> bool {
*last_sent = Instant::now();
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
use super::limits::Admission;
use super::{Access, Admin, AdminReply, ClientMsg, HostInfo, IpAddr, LibraryReply, LibraryReq, PluginReply, PluginReq, Server};
/// 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;
}
+408
View File
@@ -0,0 +1,408 @@
use super::hosting::{Open, spawn};
use super::limits::{PerAddress, ip_key};
use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block};
use super::{
Arc, AtomicBool, AtomicUsize, Bucket, Bytes, ClientMsg, CloseCode, CloseFrame, Duplex, Duration, ErrorResponse, Instant, IpAddr, Message, Meter,
NetLimits, Ordering, Read, Readers, Receiver, RecvTimeoutError, Request, Response, Role, Sender, ServerMsg, SocketAddr, TcpListener, TcpStream,
WebSocket, Write, config, io, message, shutdown, sync_channel,
};
/// 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,
}
}
}
+204
View File
@@ -0,0 +1,204 @@
use super::backend::Backend;
use super::conn::{Shared, accept};
use super::limits::{PerAddress, loopback};
use super::queue::{Blocks, HubIn, InFlight};
use super::{
Arc, AtomicBool, AtomicUsize, ClientMsg, Duration, HashMap, Hub, Inbox, Instant, JoinHandle, MAX_NAME, NetLimits, Ordering, Presence, Readers,
Room, Sender, Server, SocketAddr, TcpListener, TcpStream, channel, io,
};
/// 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);
}
}
+99
View File
@@ -0,0 +1,99 @@
use super::{Access, Arc, HashMap, IpAddr, Ipv6Addr, Mutex, PoisonError, SocketAddr};
/// 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);
}
}
}
}
+212
View File
@@ -0,0 +1,212 @@
use super::limits::ip_blocks;
use super::{
Arc, AtomicUsize, Bytes, ClientMsg, CloseCode, HashMap, IpAddr, Mutex, Ordering, PoisonError, Presence, SyncSender, TcpStream, TrySendError,
};
/// 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,
}
+1 -1
View File
@@ -30,7 +30,7 @@ pub struct NetLimits {
/// 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`).
/// A client pings this often, whatever else it sends (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
pub queue: usize,
+1
View File
@@ -17,6 +17,7 @@ chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
figcraft-color = { workspace = true }
+11
View File
@@ -245,6 +245,10 @@ pub enum Entry {
/// [`DevSnapshot`](figcraft_doc::DevSnapshot)).
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
/// A key with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256
/// hex `figcraft_format::image_hash` produces for the bytes; every key must be a plain
/// archive member name ([`figcraft_doc::ImageData::is_valid_key`]), and dimensions and byte
/// size are bounded as the entry applies (`image_of` in `docprops.rs`).
Image(String),
/// A voter's ballot in a board's vote (value: the layer ids voted on).
Ballot(String),
@@ -309,6 +313,13 @@ impl DocProp {
}
}
impl Change {
/// Whether this puts or removes an image entry.
pub(crate) fn is_image(&self) -> bool {
matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
}
}
impl Entry {
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
/// session carry their creator's client id, ids from before and ids of other forms are in
+63 -2
View File
@@ -8,6 +8,7 @@ use base64::Engine as _;
use figcraft_doc::{Collection, Comment, DevSnapshot, Document, ImageData, Style, Variable};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use crate::Error;
use crate::change::{Change, DocProp, Entry, Family, Json};
@@ -164,13 +165,68 @@ struct ImageValue {
bytes: String,
}
/// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no
/// image this app realistically opens reaches it — an entry claiming a bigger side is
/// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it
/// couldn't probe (`image_data`).
pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535;
/// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces.
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// The image an entry puts under `key`, which now holds `held`: [`image_of`], and a key that
/// already holds an image can't be given other bytes. A key always names the same bytes
/// (producers key images by their bytes; the REST importer's placeholder keys always hold the
/// same grey picture), so nobody can swap the picture under a key someone else added.
fn image_for(held: Option<&ImageData>, key: &str, value: &Json) -> Result<ImageData, Error> {
let img = image_of(key, value)?;
if held.is_some_and(|old| old.bytes != img.bytes) {
return Err(Error::Invalid(format!("image: {key} already holds other bytes")));
}
Ok(img)
}
/// The JSON value an image entry carries: its size and its encoded bytes in base64.
fn image_json(img: &ImageData) -> Result<Json, Error> {
Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) })
}
fn image_of(value: &Json) -> Result<ImageData, Error> {
/// The image a change carries, validated as a collaborator's claim (an invalid entry
/// rejects its batch, leaving every replica's store clean): a key that can name an archive
/// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/<key>`), dimensions within
/// [`MAX_IMAGE_SIDE`], decoded bytes within [`ImageData::MAX_BYTES`] (what the importers read;
/// the encoded length is capped before decoding so hostile text can't force a large
/// allocation), and a key that claims a content hash ([`ImageData::claims_content_hash`]) must
/// be exactly the lowercase sha256 of these bytes, the form `figcraft_format::image_hash`
/// writes — a poisoned content-addressed store would show the wrong image everywhere at once
/// (#1086). Other keys (the svg importer's FNV-128 keys, the REST importer's placeholders)
/// claim no content address, so only the name and size rules apply to them.
fn image_of(key: &str, value: &Json) -> Result<ImageData, Error> {
image_within(key, value, ImageData::MAX_BYTES)
}
/// [`image_of`] with `max_bytes` for the decoded size.
pub(crate) fn image_within(key: &str, value: &Json, max_bytes: u64) -> Result<ImageData, Error> {
if !ImageData::is_valid_key(key) {
return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)")));
}
let v: ImageValue = value.read("image")?;
if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE {
return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE)));
}
let max_encoded = (max_bytes / 3).saturating_mul(4).saturating_add(4);
if u64::try_from(v.bytes.len()).unwrap_or(u64::MAX) > max_encoded {
return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {max_bytes} of bytes")));
}
let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
return Err(Error::Invalid(format!("image: {} bytes pass the {max_bytes} an image may be", bytes.len())));
}
if ImageData::claims_content_hash(key) && key != sha256_hex(&bytes) {
return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to")));
}
Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height })
}
@@ -286,7 +342,7 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
doc.dev_snapshots.insert(*k, Arc::new(snap));
}
Entry::Image(k) => {
let img = image_of(value)?;
let img = image_for(doc.images.get(k), k, value)?;
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
@@ -302,6 +358,11 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
doc.dev_snapshots.remove(k);
}
Entry::Image(k) => {
// An image a layer or style shows stays: removing it would let other bytes go
// under its key, swapping the picture that layer shows ([`image_for`]).
if figcraft_doc::used_images(doc).contains(k) {
return Err(Error::Invalid(format!("image: {k} is still in use")));
}
doc.images.remove(k);
}
Entry::Ballot(k) => {
+1 -11
View File
@@ -311,17 +311,7 @@ impl Step {
apply_all(&mut work, &mut out, deletes);
// Changes that apply one by one can still fail the batch's checks together (rare): keep
// only those that pass them in order.
if view.clone().apply(&out, Mode::Strict).is_err() {
let mut w = view.clone();
out.retain(|c| {
let mut t = w.clone();
let ok = t.apply(std::slice::from_ref(c), Mode::Strict).is_ok();
if ok {
w = t;
}
ok
});
}
view.clone().apply_kept(&mut out, |_| true)?;
Ok(out)
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Image entries are checked as they apply: bytes carried under a content-hash key must be
//! exactly what that key hashes to (lowercase sha256, the form `figcraft_format::image_hash`
//! produces), a side stays within the 65 535 px bound the implementation enforces and decoded
//! bytes within its 32 MiB cap. Without this a collaborator could poison the shared
//! content-addressed image store or balloon memory (#1086).
use base64::Engine as _;
use figcraft_doc::Document;
use serde_json::json;
use sha2::{Digest as _, Sha256};
use crate::change::{Change, Entry, Json};
use crate::docprops::apply as apply_doc;
use crate::docprops::{MAX_IMAGE_SIDE, image_within};
/// The side bound the implementation enforces.
const MAX_SIDE: u32 = MAX_IMAGE_SIDE;
/// sha256 hex, computed here independently of the implementation under test.
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// An image entry putting `bytes` (`width` × `height`) under `key`.
pub(crate) fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
Change::Put {
entry: Entry::Image(key.into()),
value: Json(json!({
"width": width,
"height": height,
"bytes": base64::engine::general_purpose::STANDARD.encode(bytes),
})),
}
}
/// Bytes a collaborator hashed into the key, and different bytes they send under it.
pub(crate) fn forged() -> (String, &'static [u8]) {
(sha256_hex(b"the image the key actually names"), b"someone else's bytes")
}
#[test]
fn bytes_that_do_not_hash_to_their_content_hash_key_are_refused() {
let (key, bytes) = forged();
let mut doc = Document::new("t");
let e = match apply_doc(&mut doc, &put(&key, 2, 1, bytes)) {
Ok(_) => panic!("bytes that don't hash to their key must be refused"),
Err(e) => e,
};
assert!(e.to_string().contains("image"), "not an image refusal: {e}");
assert!(doc.images.is_empty(), "the image store was poisoned under key {key}");
}
/// An uppercase key looks like a content hash but isn't one: producers store the lowercase
/// form, so bytes matching under the uppercase form would file a second copy under a name
/// no honest lookup reaches. Refused (#1086).
#[test]
fn an_uppercase_content_hash_shaped_key_is_refused() {
let bytes = b"honestly keyed, uppercase claim".as_slice();
let mut doc = Document::new("t");
let upper = sha256_hex(bytes).to_uppercase();
let e = match apply_doc(&mut doc, &put(&upper, 2, 1, bytes)) {
Ok(_) => panic!("an uppercase key claims a content address it doesn't have"),
Err(e) => e,
};
assert!(e.to_string().contains("image"), "not an image refusal: {e}");
assert!(doc.images.is_empty(), "the store took an uppercase-keyed copy");
}
#[test]
fn an_image_that_hashes_to_its_key_applies() {
let bytes = b"a real image, honestly keyed".as_slice();
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(bytes), 2, 1, bytes)).expect("an honest image must apply unchanged");
let img = doc.images.get(&sha256_hex(bytes)).expect("the image is stored");
assert_eq!((img.width, img.height), (2, 1));
assert_eq!(img.bytes.as_slice(), bytes);
}
#[test]
fn absurd_dimensions_are_refused() {
let bytes = b"honest bytes, dishonest size".as_slice();
let key = sha256_hex(bytes);
for (w, h) in [(MAX_SIDE + 1, 10), (10, MAX_SIDE + 1), (u32::MAX, u32::MAX)] {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&key, w, h, bytes))
.err()
.unwrap_or_else(|| panic!("a {w}×{h} image must be refused, but it applied to {:?} keys", doc.images.len()));
assert!(doc.images.is_empty());
}
}
/// Bytes past the cap are refused, and so is base64 text too long to decode within it (checked
/// before decoding). The cap is `figcraft_doc::ImageData::MAX_BYTES` (256 MiB); a small one keeps
/// the test light.
#[test]
fn oversized_image_bytes_are_refused() {
let value = |bytes: &[u8]| json!({"width": 1, "height": 1, "bytes": base64::engine::general_purpose::STANDARD.encode(bytes)});
let at = vec![0u8; 1024];
image_within(&sha256_hex(&at), &Json(value(&at)), 1024).expect("exactly the cap applies");
let past = vec![0u8; 1025];
assert!(image_within(&sha256_hex(&past), &Json(value(&past)), 1024).is_err(), "one byte past the cap");
let long_text = json!({"width": 1, "height": 1, "bytes": "A".repeat(4096)});
let e = image_within("img1", &Json(long_text), 1024).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("base64 text longer"), "refused before decoding: {e:?}");
}
#[test]
fn a_zero_sized_image_still_applies() {
// The format crate stores 0×0 for bytes it can't probe (`image_data` in `figcraft_format`),
// so a shared document can legitimately carry one; the bytes still have to be honest.
let bytes = b"undecodable bytes kept from a file load".as_slice();
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(bytes), 0, 0, bytes)).expect("0×0 means the size is unknown, not absurd");
assert!(doc.images.contains_key(&sha256_hex(bytes)));
}
#[test]
fn a_key_that_is_not_a_content_hash_shape_still_applies() {
// Legacy producers key images without sha256: the svg importer stores FNV-128 keys (32 hex
// chars, re-keyed by sha256 on save), and the REST importer keeps a placeholder under the
// imageRef itself. They are not content-addressed references, so no hash is claimed; the
// dimension and size bounds still hold.
for key in ["rest-image-missing", "hash", &"f".repeat(32), &"ab".repeat(20)] {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).unwrap_or_else(|e| panic!("legacy key {key:?} refused: {e}"));
assert!(doc.images.contains_key(key));
let mut big = Document::new("t");
assert!(apply_doc(&mut big, &put(key, MAX_SIDE + 1, 1, b"placeholder bytes")).is_err(), "a legacy key must still bound dimensions");
}
}
/// A key becomes an archive member name when the file is saved (`images/<key>`), so a key that
/// isn't plain letters and digits is refused as it applies: a collaborator could otherwise write
/// `images/../../x` into everyone's saved file, which the next load drops as damaged (#1086).
#[test]
fn a_key_that_cannot_name_an_archive_member_is_refused() {
for key in ["../../evil", "a/b", "x.png", "", "with space", "\\server", &"a".repeat(129)] {
let mut doc = Document::new("t");
assert!(apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).is_err(), "key {key:?} must be refused");
assert!(doc.images.is_empty(), "key {key:?} reached the image store");
}
}
/// The cap matches what the importers read (`figcraft_doc::ImageData::MAX_BYTES`): an image a
/// document took from a `.fig`, `.sketch` or REST import reaches everyone in a live session.
#[test]
fn an_image_as_large_as_an_import_takes_still_applies() {
let bytes = vec![0u8; 40 << 20];
let mut doc = Document::new("t");
apply_doc(&mut doc, &put(&sha256_hex(&bytes), 0, 0, &bytes)).expect("a 40 MiB image is within what imports read");
assert_eq!(doc.images.len(), 1);
}
/// An image key is content-derived by every producer, so its bytes never change: a collaborator
/// can't swap the picture under a key another one added.
#[test]
fn an_image_cannot_be_replaced_under_its_key() {
let mut doc = Document::new("t");
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err(), "replaced under the same key");
assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).expect("the same image again is fine (undo, redo)");
}
/// A snapshot written before these checks may hold an image they refuse: the session still
/// opens, without that image, instead of not opening at all.
#[test]
fn a_snapshot_holding_an_image_the_checks_refuse_still_opens_without_it() {
let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
let (key, bytes) = forged();
snapshot.push(put(&key, 2, 1, bytes));
let r = crate::Replica::from_snapshot(&snapshot).expect("the snapshot opens");
assert!(!r.document().images.contains_key(&key));
}
/// A server's log written before these checks may hold an image they refuse: a restart replays
/// the edits after it instead of dropping them with the rest of the log.
#[test]
fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() {
let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
let (key, bytes) = forged();
let good = b"an honest image after it".as_slice();
let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
for j in [crate::Journal::Issued { client: 1 }, accepted(1, vec![put(&key, 2, 1, bytes)]), accepted(2, vec![put(&sha256_hex(good), 2, 1, good)])]
{
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), 2, "both batches count");
assert!(!r.document().images.contains_key(&key), "the refused image is left out");
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
}
/// A server's log written before these checks may give an image other bytes under its key (the
/// apply step allowed it then): a restart replays past that batch, keeping the first bytes and
/// every edit after it, instead of stopping there and dropping the rest of the log.
#[test]
fn a_restart_replays_past_a_logged_image_replacement() {
let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
let good = b"an honest image after it".as_slice();
let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
let journal = [
crate::Journal::Issued { client: 1 },
accepted(1, vec![put("img1", 2, 1, b"the first bytes")]),
accepted(2, vec![put("img1", 2, 1, b"other bytes")]),
accepted(3, vec![put(&sha256_hex(good), 2, 1, good)]),
];
for j in journal {
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), 3, "every batch counts");
assert_eq!(r.document().images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()), "the replacement is left out");
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
}
/// A document whose rectangle shows `img1` (the first bytes), and that rectangle.
fn in_use() -> (Document, figcraft_doc::NodeId) {
let mut doc = Document::new("t");
let page = doc.first_page().unwrap();
let id = doc.create(page, figcraft_doc::NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
doc.get_mut(id).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
(doc, id)
}
/// An image a layer or style still shows can't be removed: removing it and putting other bytes
/// under its key would swap the picture that layer shows, which giving the key other bytes
/// directly can't do (#1086).
#[test]
fn an_image_in_use_cannot_be_removed() {
let (mut doc, _) = in_use();
let remove = Change::Remove { entry: Entry::Image("img1".into()) };
assert!(apply_doc(&mut doc, &remove).is_err(), "removed while a layer shows it");
assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err());
assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
}
/// A batch that first stops using an image, then removes it (the order a diff makes), applies.
#[test]
fn an_image_no_longer_in_use_can_be_removed() {
let (doc, id) = in_use();
let mut r = crate::Replica::new(doc);
let batch = [Change::Delete { id }, Change::Remove { entry: Entry::Image("img1".into()) }];
r.apply(&batch, crate::Mode::Strict).expect("the layer goes first, then its image");
assert!(r.document().images.is_empty());
}
/// Only image entries are left out of saved history: a snapshot whose other changes don't apply
/// is still an error, not a document missing them.
#[test]
fn a_snapshot_with_a_broken_layer_change_still_fails_to_open() {
let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
// A property of a layer that isn't there: an error when applied (unlike deleting it).
let missing = figcraft_doc::NodeId(u64::MAX);
snapshot.push(Change::Set { id: missing, prop: crate::change::Prop::Name, value: Json(json!("x")) });
assert!(crate::Replica::from_snapshot(&snapshot).is_err());
}
+149
View File
@@ -174,3 +174,152 @@ fn a_journal_with_a_gap_is_refused() {
assert_eq!(errors.len(), 1, "the batch after the gap");
assert_eq!(r.seq(), 0);
}
/// Restores `state` with one more batch from another client in its log (and in its snapshot,
/// `in_snapshot`, as a server that accepted it saved it), then has `a` resume into it.
fn resume_past(s: &Server, a: &mut Client, batch: Vec<crate::Change>, in_snapshot: bool) -> Server {
let mut state = s.state().unwrap();
if in_snapshot {
state.snapshot.retain(|c| {
!batch.iter().any(|b| matches!((b, c), (crate::Change::Put { entry: x, .. }, crate::Change::Put { entry: y, .. }) if x == y))
});
state.snapshot.extend(batch.iter().cloned());
}
state.seq += 1;
state.log.push(message::Accepted { seq: state.seq, author: a.id() + 1, batch_id: 1, changes: batch });
let mut r = Server::restore(state, "", Limits::default()).unwrap();
a.disconnect();
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
let (_, out) = r.join(h).unwrap();
deliver(a, &out);
r
}
/// A saved log may hold an image a newer server refuses: a client resuming from before it still
/// catches up, without that image (#1086).
#[test]
fn a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let (key, bytes) = crate::image_tests::forged();
let r = resume_past(&s, &mut a, vec![crate::image_tests::put(&key, 2, 1, bytes)], false);
assert!(!a.view().document().images.contains_key(&key));
assert_eq!(a.view(), r.replica());
}
/// A saved log may give an image other bytes under a key the resuming client already holds (an
/// older server allowed it): the client still catches up, to what the server holds (#1086).
#[test]
fn a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let m = edit(&mut a, |d| {
d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
});
send(&mut s, a.id(), m, &mut [&mut a]);
let r = resume_past(&s, &mut a, vec![crate::image_tests::put("img1", 2, 1, b"other bytes")], true);
assert_eq!(a.view(), r.replica());
}
/// `a` adds a rectangle showing image `img1`; returns the rectangle.
fn add_image_layer(s: &mut Server, a: &mut Client) -> figcraft_doc::NodeId {
let page = s.document().first_page().unwrap();
let mut id = None;
let m = edit(a, |d| {
let r = d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(r).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
id = Some(r);
});
send(s, a.id(), m, &mut [a]);
id.unwrap()
}
/// Undoing an image's insertion still works with images in use protected from removal (#1086).
#[test]
fn undoing_an_image_layer_still_works() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let id = add_image_layer(&mut s, &mut a);
for m in a.undo(&NoRecompute).unwrap() {
send(&mut s, a.id(), m, &mut [&mut a]);
}
assert!(s.document().node(id).is_none(), "the layer is gone");
assert_eq!(a.view(), s.replica());
}
/// A server's log written before the image checks may remove an image a layer still shows: a
/// restart replays past it, keeping the image and every edit after it (#1086).
#[test]
fn a_restart_replays_past_a_logged_removal_of_an_image_in_use() {
let mut s = Server::new(base(), "", Limits::default());
s.journaling(true);
let mut a = join_as(&mut s, Access::Edit);
add_image_layer(&mut s, &mut a);
let saved = s.state().unwrap();
let page = s.document().first_page().unwrap();
let mut journal = s.take_journal();
let base_seq = saved.seq;
let next = |n: u64| base_seq + n;
journal.push(Journal::Accepted(message::Accepted {
seq: next(1),
author: a.id(),
batch_id: 90,
changes: vec![crate::Change::Remove { entry: crate::change::Entry::Image("img1".into()) }],
}));
let rename = crate::Change::Set { id: page, prop: crate::change::Prop::Name, value: crate::change::Json(serde_json::json!("Renamed")) };
journal.push(Journal::Accepted(message::Accepted { seq: next(2), author: a.id(), batch_id: 91, changes: vec![rename] }));
let mut r = Server::restore(saved, "", Limits::default()).unwrap();
for j in journal {
r.replay(j).expect("the log replays");
}
assert_eq!(r.seq(), next(2));
assert!(r.document().images.contains_key("img1"), "the image a layer shows stays");
assert_eq!(r.document().node(page).unwrap().name, "Renamed", "the edit after it is kept");
}
/// What a resuming client is told to catch up with.
fn catch_up_of(r: &mut Server, a: &mut Client) -> message::CatchUp {
a.disconnect();
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
let (id, out) = r.join(h).unwrap();
out.into_iter()
.find_map(|o| match o {
Outgoing::To(t, ServerMsg::Welcome { catch_up, .. }) if t == id => Some(catch_up),
_ => None,
})
.expect("a welcome")
}
/// A restart that left an image out of the saved history (an older server accepted it) catches
/// every resuming client up with the snapshot, even one at the same sequence number: a client
/// that kept the image from before would otherwise go on showing what the session no longer
/// has (#1086).
#[test]
fn a_resume_after_history_was_left_out_catches_up_with_the_snapshot() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let mut state = s.state().unwrap();
let (key, bytes) = crate::image_tests::forged();
state.snapshot.push(crate::image_tests::put(&key, 2, 1, bytes));
let mut r = Server::restore(state, "", Limits::default()).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on restore");
// Left out on replay.
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let state = s.state().unwrap();
let mut r = Server::restore(state.clone(), "", Limits::default()).unwrap();
let batch = vec![crate::image_tests::put(&key, 2, 1, bytes)];
r.replay(Journal::Accepted(message::Accepted { seq: state.seq + 1, author: a.id(), batch_id: 90, changes: batch })).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on replay");
}
/// A restart that left nothing out keeps resuming clients on the cheaper delta.
#[test]
fn a_resume_after_a_clean_restart_catches_up_with_the_log() {
let mut s = Server::new(base(), "", Limits::default());
let mut a = join_as(&mut s, Access::Edit);
let mut r = Server::restore(s.state().unwrap(), "", Limits::default()).unwrap();
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Delta(_)));
}
+2
View File
@@ -136,6 +136,8 @@ mod entry_ids_tests;
#[cfg(test)]
mod frac_tests;
#[cfg(test)]
mod image_tests;
#[cfg(test)]
mod journal_tests;
#[cfg(test)]
mod merge_list_tests;
+37 -2
View File
@@ -72,12 +72,47 @@ impl Replica {
/// Rebuild a replica from a [`snapshot`](Self::snapshot).
pub fn from_snapshot(changes: &[Change]) -> Result<Replica, Error> {
Self::from_snapshot_leaving_out(changes).map(|(r, _)| r)
}
/// [`from_snapshot`](Self::from_snapshot), and how many image entries it left out: a
/// snapshot saved before the image checks may hold one they refuse, and opens without it.
pub(crate) fn from_snapshot_leaving_out(changes: &[Change]) -> Result<(Replica, usize), Error> {
let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() };
r.apply(changes, Mode::Strict)?;
let mut changes = changes.to_vec();
let left_out = r.apply_kept(&mut changes, Change::is_image)?;
if r.doc.pages.is_empty() {
return Err(Error::Invalid("a snapshot needs a page".into()));
}
Ok(r)
Ok((r, left_out))
}
/// Apply `changes` strictly. When they don't apply together — history written under older
/// rules, or changes that apply one by one but fail the batch's checks together — apply those
/// that do, one by one in order, and leave the rest out of `changes`, as long as `may_leave_out`
/// allows it (otherwise that change's error). Returns how many were left out.
pub(crate) fn apply_kept(&mut self, changes: &mut Vec<Change>, may_leave_out: impl Fn(&Change) -> bool) -> Result<usize, Error> {
let mut all = self.clone();
if all.apply(changes, Mode::Strict).is_ok() {
*self = all;
return Ok(0);
}
let given = changes.len();
let mut kept = Vec::with_capacity(given);
for c in changes.drain(..) {
let mut t = self.clone();
match t.apply(std::slice::from_ref(&c), Mode::Strict) {
Ok(_) => {
*self = t;
kept.push(c);
}
Err(e) if !may_leave_out(&c) => return Err(e),
Err(_) => {}
}
}
let left_out = given - kept.len();
*changes = kept;
Ok(left_out)
}
/// The whole state as changes that build it from nothing (pages and their trees in order,
+22 -3
View File
@@ -77,6 +77,10 @@ pub struct Server {
signals: BTreeSet<u32>,
/// Who spotlights now (a newcomer is told).
spotlight: Option<u32>,
/// Saved history this server restored or replayed left image entries out (or may give a
/// resuming client other bytes under a key it holds): every resuming client catches up with
/// the snapshot, not the log.
resync: bool,
}
/// The most client ids a session remembers for reconnecting (the oldest disconnected ones go
@@ -127,6 +131,7 @@ impl Server {
journal: None,
signals: BTreeSet::new(),
spotlight: None,
resync: false,
}
}
@@ -148,7 +153,7 @@ impl Server {
/// reconnect with their ids). A log that doesn't lead up to the sequence number is dropped
/// (clients then catch up with a snapshot).
pub fn restore(state: SessionState, token: impl Into<String>, limits: Limits) -> Result<Server, Error> {
let replica = Replica::from_snapshot(&state.snapshot)?;
let (replica, left_out) = Replica::from_snapshot_leaving_out(&state.snapshot)?;
let mut server = Server::new(replica.document().clone(), token, limits);
server.state = replica;
server.session = state.session;
@@ -158,6 +163,12 @@ impl Server {
server.deleted = state.deleted;
server.removed = state.removed;
let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq));
// History saved before the image checks may hold image entries they refuse. The snapshot
// opens without them, and a saved log putting images may give a resuming client other
// bytes under a key it holds, which this state can't check: either way resuming clients
// catch up with the snapshot.
let puts_images = state.log.iter().flat_map(|a| &a.changes).any(Change::is_image);
server.resync = left_out > 0 || puts_images;
if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) {
for a in state.log {
server.remember(a);
@@ -198,10 +209,16 @@ impl Server {
Ok(())
}
Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()),
Journal::Accepted(acc) => {
Journal::Accepted(mut acc) => {
if acc.seq != self.seq + 1 {
return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq)));
}
// A log written before the image checks may hold an image entry they refuse: the
// batch replays without it, so the edits after it aren't dropped with the rest of
// the log, and resuming clients catch up with the snapshot.
if acc.changes.iter().any(Change::is_image) && self.state.clone().apply_kept(&mut acc.changes, Change::is_image)? > 0 {
self.resync = true;
}
self.accept(acc.author, self.seq, &acc.changes)?;
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
self.next_client = self.next_client.max(acc.author.saturating_add(1));
@@ -284,7 +301,9 @@ impl Server {
// The log holds every batch after `floor`.
let floor = self.log.front().map_or(self.seq, |a| a.seq.saturating_sub(1));
let catch_up = match resume {
Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()),
Some(r) if !self.resync && r.seq <= self.seq && r.seq >= floor => {
CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect())
}
_ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?),
};
let (id, secret) = match resume {
+1 -1
View File
@@ -3,7 +3,7 @@ name = "figcraft-color"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft colour and paint: RGBA colours, HSB/HSL, hex/CSS parsing, solid/gradient/image paints, blend modes"
description = "FigCraft colour and paint: RGBA colours, HSB/HSL/LCH/OKLCH, hex/CSS parsing, solid/gradient/image paints, blend modes"
[lints]
workspace = true
+2 -1
View File
@@ -1,4 +1,5 @@
//! HSB (HSV) and HSL, the models the colour picker offers besides RGB and hex.
//! HSB (HSV) and HSL, two of the models the colour picker offers besides RGB and hex (LCH and
//! OKLCH are in `lch.rs`).
use crate::Color;
use serde::{Deserialize, Serialize};
+164
View File
@@ -0,0 +1,164 @@
//! CIE LCH (CSS `lch()`, Lab with a D50 white) and OKLCH (CSS `oklch()`), the perceptual models
//! the colour picker offers. Colours are read as sRGB, like the hex, RGB, HSL and HSB fields.
//!
//! The matrices are the ones CSS Color 4 publishes (sRGB ↔ XYZ D65, Bradford D65 ↔ D50) and
//! Björn Ottosson's published OKLab matrices.
use crate::Color;
use crate::profile::{apply as mul, decode, encode};
use serde::{Deserialize, Serialize};
/// CIE LCH: lightness 0..100, chroma 0.. (about 0..150 for real colours), hue 0..360.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Lch {
pub l: f32,
pub c: f32,
pub h: f32,
}
/// OKLCH: lightness 0..1, chroma 0.. (about 0..0.4 for real colours), hue 0..360.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Oklch {
pub l: f32,
pub c: f32,
pub h: f32,
}
type V3 = [f64; 3];
fn linear_rgb(c: Color) -> V3 {
[decode(c.r), decode(c.g), decode(c.b)]
}
const SRGB_TO_XYZ: [V3; 3] = [
[0.412_390_799_265_959_34, 0.357_584_339_383_878, 0.180_480_788_401_834_3],
[0.212_639_005_871_510_27, 0.715_168_678_767_756, 0.072_192_315_360_733_71],
[0.019_330_818_715_591_82, 0.119_194_779_794_625_98, 0.950_532_152_249_660_7],
];
const XYZ_TO_SRGB: [V3; 3] = [
[3.240_969_941_904_522_6, -1.537_383_177_570_094, -0.498_610_760_293_003_4],
[-0.969_243_636_280_879_6, 1.875_967_501_507_720_2, 0.041_555_057_407_175_59],
[0.055_630_079_696_993_66, -0.203_976_958_888_976_52, 1.056_971_514_242_878_6],
];
const D65_TO_D50: [V3; 3] = [
[1.047_929_820_840_548_8, 0.022_946_793_341_019_088, -0.050_192_229_543_135_57],
[0.029_627_815_688_159_344, 0.990_434_484_573_249, -0.017_073_825_029_385_14],
[-0.009_243_058_152_591_178, 0.015_055_144_896_577_895, 0.751_874_289_958_000_8],
];
const D50_TO_D65: [V3; 3] = [
[0.955_473_452_704_218_2, -0.023_098_536_874_261_423, 0.063_259_308_661_021_7],
[-0.028_369_706_963_208_136, 1.009_995_458_005_822_6, 0.021_041_398_966_943_008],
[0.012_314_001_688_319_899, -0.020_507_696_433_477_912, 1.330_365_936_608_075_3],
];
const D50_WHITE: V3 = [0.3457 / 0.3585, 1.0, (1.0 - 0.3457 - 0.3585) / 0.3585];
const KAPPA: f64 = 24389.0 / 27.0;
const EPSILON: f64 = 216.0 / 24389.0;
const SRGB_TO_LMS: [V3; 3] = [
[0.412_221_470_8, 0.536_332_536_3, 0.051_445_992_9],
[0.211_903_498_2, 0.680_699_545_1, 0.107_396_956_6],
[0.088_302_461_9, 0.281_718_837_6, 0.629_978_700_5],
];
const LMS_TO_OKLAB: [V3; 3] = [
[0.210_454_255_3, 0.793_617_785_0, -0.004_072_046_8],
[1.977_998_495_1, -2.428_592_205_0, 0.450_593_709_9],
[0.025_904_037_1, 0.782_771_766_2, -0.808_675_766_0],
];
const OKLAB_TO_LMS: [V3; 3] =
[[1.0, 0.396_337_777_4, 0.215_803_757_3], [1.0, -0.105_561_345_8, -0.063_854_172_8], [1.0, -0.089_484_177_5, -1.291_485_548_0]];
const LMS_TO_SRGB: [V3; 3] = [
[4.076_741_662_1, -3.307_711_591_3, 0.230_969_929_2],
[-1.268_438_004_6, 2.609_757_401_1, -0.341_319_396_5],
[-0.004_196_086_3, -0.703_418_614_7, 1.707_614_701_0],
];
fn lab_from_color(c: Color) -> V3 {
let xyz = mul(&D65_TO_D50, mul(&SRGB_TO_XYZ, linear_rgb(c)));
let f = |v: f64| if v > EPSILON { v.cbrt() } else { (KAPPA * v + 16.0) / 116.0 };
let [fx, fy, fz] = [f(xyz[0] / D50_WHITE[0]), f(xyz[1] / D50_WHITE[1]), f(xyz[2] / D50_WHITE[2])];
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
fn linear_from_lab([l, a, b]: V3) -> V3 {
let fy = (l + 16.0) / 116.0;
let fx = fy + a / 500.0;
let fz = fy - b / 200.0;
let inv = |f: f64| if f.powi(3) > EPSILON { f.powi(3) } else { (116.0 * f - 16.0) / KAPPA };
let y = if l > KAPPA * EPSILON { fy.powi(3) } else { l / KAPPA };
let xyz = [inv(fx) * D50_WHITE[0], y * D50_WHITE[1], inv(fz) * D50_WHITE[2]];
mul(&XYZ_TO_SRGB, mul(&D50_TO_D65, xyz))
}
fn oklab_from_color(c: Color) -> V3 {
let lms = mul(&SRGB_TO_LMS, linear_rgb(c));
mul(&LMS_TO_OKLAB, [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()])
}
fn linear_from_oklab(lab: V3) -> V3 {
let lms = mul(&OKLAB_TO_LMS, lab);
mul(&LMS_TO_SRGB, [lms[0].powi(3), lms[1].powi(3), lms[2].powi(3)])
}
/// Lab → (lightness, chroma, hue); grey has hue 0.
fn polar([l, a, b]: V3, grey: f64) -> (f32, f32, f32) {
let c = a.hypot(b);
let h = if c < grey { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
(l as f32, c as f32, h as f32)
}
fn finite(v: f32) -> f64 {
if v.is_finite() { f64::from(v) } else { 0.0 }
}
fn in_gamut(rgb: V3) -> bool {
rgb.iter().all(|v| (-1e-6..=1.0 + 1e-6).contains(v))
}
/// The sRGB colour for a lightness, chroma and hue: out of gamut, the chroma comes down (a binary
/// search) until it fits, so lightness and hue stay what was asked for, as CSS gamut mapping does.
fn map_to_srgb(l: f64, c: f64, h: f64, to_linear_rgb: impl Fn(V3) -> V3) -> Color {
let (sin, cos) = h.to_radians().sin_cos();
let lab = |c: f64| [l, c * cos, c * sin];
let mut rgb = to_linear_rgb(lab(c));
if !in_gamut(rgb) {
let (mut lo, mut hi) = (0.0, c);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if in_gamut(to_linear_rgb(lab(mid))) {
lo = mid;
} else {
hi = mid;
}
}
rgb = to_linear_rgb(lab(lo));
}
Color::new(encode(rgb[0]), encode(rgb[1]), encode(rgb[2]), 1.0)
}
impl Lch {
pub fn from_color(c: Color) -> Self {
let (l, c, h) = polar(lab_from_color(c), 0.02);
Self { l, c, h }
}
/// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show.
pub fn to_color(self) -> Color {
let l = finite(self.l).clamp(0.0, 100.0);
let c = finite(self.c).clamp(0.0, 230.0);
map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_lab)
}
}
impl Oklch {
pub fn from_color(c: Color) -> Self {
let (l, c, h) = polar(oklab_from_color(c), 0.000_2);
Self { l, c, h }
}
/// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show.
pub fn to_color(self) -> Color {
let l = finite(self.l).clamp(0.0, 1.0);
let c = finite(self.c).clamp(0.0, 0.5);
map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_oklab)
}
}
+105
View File
@@ -0,0 +1,105 @@
use super::*;
fn close(a: f32, b: f32, tol: f32) -> bool {
(a - b).abs() <= tol
}
#[test]
fn lch_matches_css_reference_values() {
// CSS Color 4 values for sRGB primaries and white (D50 Lab).
let red = Lch::from_color(Color::from_hex("FF0000").unwrap());
assert!(close(red.l, 54.29, 0.05) && close(red.c, 106.84, 0.1) && close(red.h, 40.85, 0.1), "{red:?}");
let white = Lch::from_color(Color::WHITE);
assert!(close(white.l, 100.0, 0.01) && white.c < 0.05, "{white:?}");
assert_eq!(Lch::from_color(Color::BLACK).l.round(), 0.0);
}
#[test]
fn oklch_matches_reference_values() {
let red = Oklch::from_color(Color::from_hex("FF0000").unwrap());
assert!(close(red.l, 0.628, 0.001) && close(red.c, 0.2577, 0.001) && close(red.h, 29.23, 0.1), "{red:?}");
let white = Oklch::from_color(Color::WHITE);
assert!(close(white.l, 1.0, 0.001) && white.c < 0.001 && white.h == 0.0, "{white:?}");
}
#[test]
fn lch_and_oklch_round_trip_through_srgb() {
for hex in ["0D99FF", "FF0000", "00FF00", "0000FF", "FFFFFF", "000000", "808080", "663399", "F24E1E", "1ABC9C"] {
let c = Color::from_hex(hex).unwrap();
assert_eq!(Lch::from_color(c).to_color().to_hex(), hex, "{hex} via LCH");
assert_eq!(Oklch::from_color(c).to_color().to_hex(), hex, "{hex} via OKLCH");
}
}
#[test]
fn out_of_gamut_keeps_lightness_and_hue() {
// Far more chroma than sRGB holds: the chroma comes down, lightness and hue stay.
let asked = Oklch { l: 0.7, c: 0.45, h: 150.0 };
let got = Oklch::from_color(asked.to_color());
assert!(close(got.l, 0.7, 0.01) && close(got.h, 150.0, 1.5) && got.c < 0.45, "{got:?}");
let asked = Lch { l: 50.0, c: 200.0, h: 300.0 };
let got = Lch::from_color(asked.to_color());
assert!(close(got.l, 50.0, 1.0) && close(got.h, 300.0, 2.0) && got.c < 200.0, "{got:?}");
}
#[test]
fn junk_values_never_panic() {
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1e30, 1e30, -5.0, 0.0] {
let a = Lch { l: v, c: v, h: v }.to_color();
let b = Oklch { l: v, c: v, h: v }.to_color();
for x in [a.r, a.g, a.b, b.r, b.g, b.b] {
assert!(x.is_finite() && (0.0..=1.0).contains(&x));
}
}
let nan = Color { r: f32::NAN, g: 2.0, b: -1.0, a: 1.0 };
assert!(Lch::from_color(nan.sanitized()).l.is_finite());
assert!(Oklch::from_color(nan.sanitized()).l.is_finite());
}
#[test]
fn css_reads_lch_and_oklch() {
assert_eq!(Color::parse_css("oklch(62.8% 0.2577 29.23)").unwrap().to_hex(), "FF0000");
assert_eq!(Color::parse_css("oklch(0.628 0.2577 29.23deg / 50%)").unwrap().to_rgba8(), [255, 0, 0, 128]);
assert_eq!(Color::parse_css("lch(54.29 106.84 40.85)").unwrap().to_hex(), "FF0000");
assert_eq!(Color::parse_css("LCH(100% 0 0)").unwrap().to_hex(), "FFFFFF");
assert!(Color::parse_css("oklch(nan 0 0)").is_none());
assert!(Color::parse_css("lch(50)").is_none());
}
#[test]
fn css_hue_units_and_none() {
let blue = Color::parse_css("oklch(0.6 0.2 260)").unwrap();
for h in ["260deg", "0.7222222turn", "288.8888grad", "4.537856rad"] {
assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}");
}
assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex());
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]);
}
#[test]
fn css_lch_and_oklch_junk_is_refused_or_in_range() {
let long = "9".repeat(100_000);
let junk = [
"oklch()".to_string(),
"oklch(,,)".into(),
"lch(1 2 3 4 5)".into(),
"oklch(0.5 0.1 1e40)".into(),
"lch(inf 0 0)".into(),
"oklch(0.5 0.1 deg)".into(),
"lch(50 50 50 / / /)".into(),
"oklch(🟥 0 0)".into(),
"lch(\u{0} 0 0)".into(),
format!("oklch({long} {long} {long})"),
format!("lch(50 50 {long}turn)"),
];
for j in &junk {
if let Some(c) = Color::parse_css(j) {
for v in [c.r, c.g, c.b, c.a] {
assert!(v.is_finite() && (0.0..=1.0).contains(&v), "{j:.40}: {c:?}");
}
}
}
for j in ["oklch()", "lch(1 2 3 4 5)", "lch(inf 0 0)", "oklch(0.5 0.1 deg)", "oklch(🟥 0 0)", "oklch(0.5 0.1 1e40)"] {
assert!(Color::parse_css(j).is_none(), "{j}");
}
}
+55 -1
View File
@@ -11,10 +11,12 @@ use serde::{Deserialize, Serialize};
mod blend;
mod hsx;
mod icc;
mod lch;
mod named;
mod profile;
pub use blend::BlendMode;
pub use hsx::*;
pub use lch::{Lch, Oklch};
pub use profile::{ColorProfile, ProfileConversion};
/// A non-premultiplied sRGB colour with alpha, components in `0..=1`. Deserializes from
@@ -147,7 +149,8 @@ impl Color {
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, `lch()`, `oklch()`, a CSS colour
/// name or `transparent`. LCH and OKLCH outside sRGB come back as the nearest sRGB colour.
pub fn parse_css(s: &str) -> Option<Self> {
let t = s.trim().to_ascii_lowercase();
if t.starts_with('#') {
@@ -186,6 +189,15 @@ impl Color {
}
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
// Percentages per CSS Color 4: OKLCH 100% is lightness 1, chroma 0.4; LCH 100% is 100 and 150.
if let Some(inner) = t.strip_prefix("oklch(").and_then(|r| r.strip_suffix(')')) {
let ([l, c, h], alpha) = css_polar(inner, 1.0, 0.4)?;
return Some(Oklch { l, c, h }.to_color().with_alpha(alpha));
}
if let Some(inner) = t.strip_prefix("lch(").and_then(|r| r.strip_suffix(')')) {
let ([l, c, h], alpha) = css_polar(inner, 100.0, 150.0)?;
return Some(Lch { l, c, h }.to_color().with_alpha(alpha));
}
if t == "transparent" {
return Some(Color::TRANSPARENT);
}
@@ -233,6 +245,46 @@ fn css_args(inner: &str) -> Vec<String> {
inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect()
}
/// The arguments of `lch()` / `oklch()`: lightness, chroma, hue and alpha. Percentages are of
/// `l_full` / `c_full`; `none` is 0.
fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
let parts = css_args(inner);
if !(3..=4).contains(&parts.len()) {
return None;
}
let l = css_number(parts.first()?, l_full)?;
let c = css_number(parts.get(1)?, c_full)?;
let h = css_hue(parts.get(2)?)?;
let alpha = match parts.get(3).map(String::as_str) {
None | Some("none") => 1.0,
Some(a) => parse_alpha(a)?,
};
Some(([l, c, h], alpha))
}
/// A CSS number, a percentage of `full` or `none` (0); finite only.
fn css_number(p: &str, full: f32) -> Option<f32> {
let v = match p.strip_suffix('%') {
_ if p == "none" => 0.0,
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
None => p.parse::<f32>().ok()?,
};
v.is_finite().then_some(v)
}
/// A CSS hue in degrees: a number, `deg`, `rad`, `grad`, `turn` or `none` (0); finite only.
fn css_hue(p: &str) -> Option<f32> {
if p == "none" {
return Some(0.0);
}
let (num, per_degree) = [("grad", 400.0 / 360.0), ("turn", 1.0 / 360.0), ("rad", std::f32::consts::PI / 180.0), ("deg", 1.0)]
.into_iter()
.find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k)))
.unwrap_or((p, 1.0));
let v = num.parse::<f32>().ok()? / per_degree;
v.is_finite().then_some(v)
}
fn parse_alpha(a: &str) -> Option<f32> {
if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }
}
@@ -671,6 +723,8 @@ impl Paint {
#[cfg(test)]
mod gradient_tests;
#[cfg(test)]
mod lch_tests;
#[cfg(test)]
mod pattern_tests;
#[cfg(test)]
mod profile_tests;
+2 -2
View File
@@ -172,13 +172,13 @@ fn outside(l: f64) -> bool {
const ENCODE_STEPS: usize = 4096;
/// The sRGB transfer function, encoded value → linear light.
fn decode(v: f32) -> f64 {
pub(crate) fn decode(v: f32) -> f64 {
let v = f64::from(if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// The sRGB transfer function, linear light → encoded value (clipped to 0..=1).
fn encode(l: f64) -> f32 {
pub(crate) fn encode(l: f64) -> f32 {
let l = if l.is_finite() { l.clamp(0.0, 1.0) } else { 0.0 };
(if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 }) as f32
}
+3 -1
View File
@@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() {
assert!(d.validate().is_empty());
}
/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer
/// that isn't a page, and repairs into a file with a page.
#[test]
fn pages_can_be_inserted_onto_a_page_less_base() {
let mut d = Document::without_pages("snapshot");
assert!(d.pages.is_empty() && d.node_count() == 0);
let p = Node::new(
NodeId(C3 | 1),
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() },
"P",
0.0,
0.0,
+1
View File
@@ -28,6 +28,7 @@ fn colourful() -> (Document, NodeId) {
close_on_outside: false,
background: Some(RED),
offset: None,
anchor: Default::default(),
}],
transition: Default::default(),
duration_ms: 300,
+61
View File
@@ -0,0 +1,61 @@
//! Ruler guides survive a save and reopen (#1476).
use crate::{Document, Guide, Node, NodeKind};
/// A guide: vertical (at an x offset) or horizontal (at a y offset).
fn guide(vertical: bool, offset: f64) -> Guide {
Guide { vertical, offset }
}
/// A page's guides are written once and read back onto the page.
#[test]
fn page_guides_round_trip_through_json() {
let mut d = Document::new("a");
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().guides_mut().push(guide(true, 120.0));
let json = serde_json::to_string(d.node(page).unwrap()).unwrap();
assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back.guides(), &[guide(true, 120.0)]);
assert_eq!(back, *d.node(page).unwrap());
}
/// A frame's guides round trip too, and a layer without guides writes no `guides` key.
#[test]
fn frame_guides_round_trip_and_none_are_left_out() {
let mut d = Document::new("a");
let page = d.first_page().unwrap();
let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
for id in [page, frame] {
let json = serde_json::to_value(d.node(id).unwrap()).unwrap();
assert!(json.get("guides").is_none(), "{json}");
}
d.node_mut(frame).unwrap().guides_mut().push(guide(false, 40.0));
let json = serde_json::to_string(d.node(frame).unwrap()).unwrap();
assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back, *d.node(frame).unwrap());
}
/// A page saved with two `guides` keys reads both lists, in file order, and writes them once.
#[test]
fn a_page_written_with_two_guides_lists_opens_with_both() {
// How a page was saved after its file had been reopened: the page kind's list (guides added
// since), then the layer's (the ones the file had).
let json = r#"{"id": 1, "name": "Page 1", "type": "page", "guides": [{"vertical": true, "offset": 5.0}],
"width": 0.0, "height": 0.0, "guides": [{"vertical": false, "offset": 7.0}]}"#;
let n: Node = serde_json::from_str(json).unwrap();
assert!(matches!(n.kind, NodeKind::Page { .. }));
assert_eq!(n.guides(), &[guide(true, 5.0), guide(false, 7.0)]);
let again: Node = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap();
assert_eq!(again.guides(), n.guides());
}
/// A `guides` value that isn't a list of guides is still refused.
#[test]
fn a_bad_guides_list_is_still_an_error() {
for bad in [r#"3"#, r#"[{"vertical": "no"}]"#, r#"{"offset": 1}"#] {
let json = format!(r#"{{"id": 1, "name": "F", "type": "frame", "width": 1.0, "height": 1.0, "guides": {bad}}}"#);
assert!(serde_json::from_str::<Node>(&json).is_err(), "{bad}");
}
}
+20
View File
@@ -85,3 +85,23 @@ fn validate_is_linear_in_a_wide_parent() {
doc.get_mut(page).unwrap().children.retain(|c| *c != kid);
assert!(doc.validate().iter().any(|m| m.contains("not listed in parent")));
}
/// Repair lists every unlisted page once, by set lookups rather than a scan of the page list per
/// page (#1078: a file with many pages made repair quadratic).
#[test]
fn repair_relists_many_unlisted_pages_once_each() {
let mut doc = Document::new("d");
for i in 0..5_000 {
doc.add_page(&format!("P{i}"));
}
let all: Vec<_> = doc.pages.clone();
// Half the pages fall off the list.
doc.pages.retain(|p| p.0 % 2 == 0);
doc.repair();
let mut listed = doc.pages.clone();
listed.sort_unstable();
let mut want = all;
want.sort_unstable();
assert_eq!(listed, want, "every page is listed again, once");
assert!(doc.validate().is_empty());
}
+8
View File
@@ -110,3 +110,11 @@ fn the_highest_space_counts_every_kind_of_id() {
assert_eq!(id_space("S:99999999999999999999999", "S:"), 0, "too big for a number");
assert_eq!(mode_number("1:0"), None);
}
#[test]
fn nodeid_rejects_plus_sign() {
assert!("+12".parse::<crate::NodeId>().is_err());
assert!("0:+12".parse::<crate::NodeId>().is_err());
assert_eq!("12".parse::<crate::NodeId>(), Ok(crate::NodeId(12)));
assert_eq!("0:12".parse::<crate::NodeId>(), Ok(crate::NodeId(12)));
}
+33 -8
View File
@@ -67,6 +67,10 @@ impl std::str::FromStr for NodeId {
// Accept "12" and Figma-style "0:12".
let t = s.trim();
let t = t.rsplit(':').next().unwrap_or(t);
if t.starts_with('+') {
// `u64::parse` accepts a leading '+'; ids are digits only.
return "-".parse::<u64>().map(NodeId);
}
t.parse::<u64>().map(NodeId)
}
}
@@ -161,6 +165,26 @@ pub struct ImageData {
pub height: u32,
}
impl ImageData {
/// Largest encoded image a document takes from outside: an image member of a `.fig` or
/// `.sketch` file, an image file a REST import reads, an image entry from a live session.
pub const MAX_BYTES: u64 = 256 * 1024 * 1024;
/// Whether `key` claims to be a content hash: 64 hex digits, the shape of the sha256 hex
/// `figcraft_format::image_hash` makes. Bytes stored under such a key must hash to it.
pub fn claims_content_hash(key: &str) -> bool {
key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes,
/// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key
/// becomes an archive member name (`images/<key>`) when the file is saved, so only these are
/// read back from a file or taken from a collaborator: never `/`, `.` or other path text.
pub fn is_valid_key(key: &str) -> bool {
(1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
}
/// A whole design file.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -407,13 +431,8 @@ impl Document {
/// Add a page at the end.
pub fn add_page(&mut self, name: &str) -> NodeId {
let id = self.alloc_id();
let node = Node::new(
id,
NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
name,
0.0,
0.0,
);
let node =
Node::new(id, NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, name, 0.0, 0.0);
self.nodes.insert(id, Arc::new(node));
self.dirty.touch(id);
self.pages.push(id);
@@ -1143,8 +1162,10 @@ impl Document {
Arc::make_mut(&mut self.libraries).store = store;
}
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
// Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
let mut listed: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
for (k, n) in self.nodes.iter() {
if n.is_page() && !self.pages.contains(k) && Some(*k) != store {
if n.is_page() && Some(*k) != store && listed.insert(*k) {
self.pages.push(*k);
}
}
@@ -1258,6 +1279,8 @@ mod diff_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
mod guides_tests;
#[cfg(test)]
mod handoff_tests;
#[cfg(test)]
mod ids_tests;
@@ -1270,6 +1293,8 @@ mod proto_expr_parse_tests;
#[cfg(test)]
mod proto_expr_tests;
#[cfg(test)]
mod proto_overlay_anchor_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod vector_regions_tests;
+1 -1
View File
@@ -260,7 +260,7 @@ impl Document {
let id = self.alloc_id();
let node = Node::new(
id,
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() },
STORE_NAME,
0.0,
0.0,
+83 -13
View File
@@ -1,7 +1,10 @@
//! Nodes: one struct with the properties every node can have, and a kind with per-type data.
use crate::NodeId;
use crate::props::*;
use crate::props::{
AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap,
StrokeProps,
};
use crate::proto::{Interaction, Overflow, ScrollPosition};
use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId};
use crate::text::TextData;
@@ -63,6 +66,9 @@ pub enum PropDef {
}
/// What a node is, with per-type data.
// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
// Page's guides moved to the node (#1476).
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum NodeKind {
@@ -73,8 +79,6 @@ pub enum NodeKind {
flows: Vec<crate::proto::Flow>,
#[serde(default)]
prototype: crate::proto::PrototypeSettings,
#[serde(default)]
guides: Vec<Guide>,
},
Frame,
Group,
@@ -469,8 +473,8 @@ pub struct Node {
pub mask_type: MaskType,
#[serde(default, skip_serializing_if = "is_default")]
pub grids: Vec<LayoutGrid>,
/// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]).
#[serde(default, skip_serializing_if = "is_default")]
/// A page's or frame's ruler guides, in its own coordinates (see [`guides_key`]).
#[serde(flatten, with = "guides_key")]
pub guides: Vec<Guide>,
#[serde(default, skip_serializing_if = "is_default")]
pub exports: Vec<ExportSetting>,
@@ -575,6 +579,77 @@ fn is_identity(v: &Affine) -> bool {
*v == identity()
}
/// A layer's `guides` key: left out when there are none, and every `guides` list the layer has is
/// read. Pages used to keep their guides in [`NodeKind::Page`] while the layer field took the
/// `guides` key on reading, so a reopened page lost its guides, and saving it then wrote two
/// `guides` keys, which a plain field refuses (#1476). Both lists are kept, in file order.
mod guides_key {
use super::Guide;
use serde::de::{IgnoredAny, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serializer};
use std::fmt;
const KEY: &str = "guides";
/// Write `guides` as the layer's `guides` key, or nothing when there are none.
pub fn serialize<S: Serializer>(guides: &[Guide], s: S) -> Result<S::Ok, S::Error> {
let mut map = s.serialize_map(None)?;
if !guides.is_empty() {
map.serialize_entry(KEY, guides)?;
}
map.end()
}
/// Read every `guides` list among the layer's fields, joined in file order.
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Guide>, D::Error> {
d.deserialize_map(Lists)
}
/// Reads the `guides` lists of a layer's fields and skips the other fields.
struct Lists;
impl<'de> Visitor<'de> for Lists {
type Value = Vec<Guide>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a layer's fields")
}
/// Every `guides` value joined, in order; a malformed one is an error.
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Vec<Guide>, A::Error> {
let mut guides = Vec::new();
while let Some(key) = map.next_key::<Key>()? {
if key.0 {
guides.extend(map.next_value::<Vec<Guide>>()?);
} else {
map.next_value::<IgnoredAny>()?;
}
}
Ok(guides)
}
}
/// Whether a field name is `guides` (without copying the name).
struct Key(bool);
impl<'de> Deserialize<'de> for Key {
/// Compare a field name with `guides` as it is read.
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Name;
impl Visitor<'_> for Name {
type Value = Key;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a field name")
}
/// `guides` or another field.
fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Key, E> {
Ok(Key(s == KEY))
}
}
d.deserialize_identifier(Name)
}
}
}
impl Node {
/// A node with Figma's defaults for its kind.
pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, width: f64, height: f64) -> Self {
@@ -709,17 +784,12 @@ impl Node {
/// The ruler guides on this page or frame, in its own coordinates.
pub fn guides(&self) -> &[Guide] {
match &self.kind {
NodeKind::Page { guides, .. } => guides,
_ => &self.guides,
}
&self.guides
}
/// The ruler guides on this page or frame, to edit.
pub fn guides_mut(&mut self) -> &mut Vec<Guide> {
match &mut self.kind {
NodeKind::Page { guides, .. } => guides,
_ => &mut self.guides,
}
&mut self.guides
}
/// Children in paint order, bottom to top: the child list, reversed in an auto layout frame
+51 -1
View File
@@ -1233,11 +1233,14 @@ pub enum ExportFormat {
Svg,
Pdf,
Webp,
/// Android Vector Drawable (an `.xml` drawable resource).
VectorDrawable,
}
impl ExportFormat {
/// Every format, in the order the export menus list them.
pub const ALL: [ExportFormat; 5] = [ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp];
pub const ALL: [ExportFormat; 6] =
[ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp, ExportFormat::VectorDrawable];
/// Pixel formats (their size depends on the export scale).
pub fn is_raster(self) -> bool {
@@ -1252,9 +1255,15 @@ impl ExportFormat {
ExportFormat::Svg => "SVG",
ExportFormat::Pdf => "PDF",
ExportFormat::Webp => "WebP",
ExportFormat::VectorDrawable => "XML",
}
}
/// Formats whose output is vector (one file whatever the scale).
pub fn is_vector(self) -> bool {
matches!(self, ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable)
}
pub fn ext(self) -> &'static str {
match self {
ExportFormat::Png => "png",
@@ -1262,6 +1271,7 @@ impl ExportFormat {
ExportFormat::Svg => "svg",
ExportFormat::Pdf => "pdf",
ExportFormat::Webp => "webp",
ExportFormat::VectorDrawable => "xml",
}
}
pub fn from_ext(s: &str) -> Option<Self> {
@@ -1271,6 +1281,7 @@ impl ExportFormat {
"svg" => Some(ExportFormat::Svg),
"pdf" => Some(ExportFormat::Pdf),
"webp" => Some(ExportFormat::Webp),
"xml" | "vd" | "vectordrawable" => Some(ExportFormat::VectorDrawable),
_ => None,
}
}
@@ -1439,6 +1450,44 @@ pub struct ExportSetting {
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
#[serde(skip_serializing_if = "is_true")]
pub svg_simplify_stroke: bool,
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
pub vd_compat: VdCompat,
}
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
/// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VdCompat {
/// The closest drawing, with a warning for each approximation.
#[default]
BestEffort,
/// Never approximate: what can't be drawn exactly is left out, with a warning.
Exact,
/// Write what maps, leave out the rest, no warnings.
Ignore,
}
impl VdCompat {
pub const ALL: [VdCompat; 3] = [VdCompat::BestEffort, VdCompat::Exact, VdCompat::Ignore];
fn is_default(&self) -> bool {
*self == VdCompat::BestEffort
}
/// The name commands, files and the CLI use ("bestEffort").
pub fn key(self) -> &'static str {
match self {
VdCompat::BestEffort => "bestEffort",
VdCompat::Exact => "exact",
VdCompat::Ignore => "ignore",
}
}
pub fn from_key(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.key() == s)
}
}
impl Default for ExportSetting {
@@ -1453,6 +1502,7 @@ impl Default for ExportSetting {
svg_outline_text: true,
svg_id_attribute: false,
svg_simplify_stroke: true,
vd_compat: VdCompat::BestEffort,
}
}
}
+44
View File
@@ -507,6 +507,47 @@ impl OverlayPosition {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum OverlayAnchor {
#[default]
Screen,
TriggerInside,
TriggerOutside,
}
impl OverlayAnchor {
/// The default (the screen places the overlay), which files and JSON leave out.
pub fn is_screen(&self) -> bool {
*self == OverlayAnchor::Screen
}
/// Gives None for Screen and for Manual.
pub fn offset(self, position: OverlayPosition, trigger: [f64; 2], overlay: [f64; 2]) -> Option<[f64; 2]> {
use OverlayPosition as P;
let ([tw, th], [ow, oh]) = (trigger, overlay);
let outside = match self {
OverlayAnchor::Screen => return None,
OverlayAnchor::TriggerInside => false,
OverlayAnchor::TriggerOutside => true,
};
let x = match position {
P::TopLeft | P::BottomLeft => 0.0,
P::Center | P::TopCenter | P::BottomCenter => (tw - ow) / 2.0,
P::TopRight | P::BottomRight => tw - ow,
P::Manual => return None,
};
let y = match position {
P::TopLeft | P::TopCenter | P::TopRight if outside => -oh,
P::TopLeft | P::TopCenter | P::TopRight => 0.0,
P::BottomLeft | P::BottomCenter | P::BottomRight if outside => th,
P::BottomLeft | P::BottomCenter | P::BottomRight => th - oh,
P::Center | P::Manual => (th - oh) / 2.0,
};
Some([x, y])
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Action {
@@ -538,6 +579,9 @@ pub enum Action {
/// opened it (x, y). None = right below that layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
offset: Option<[f64; 2]>,
/// The incumbent has no such option. The .fig export writes it as a manual offset.
#[serde(default, skip_serializing_if = "OverlayAnchor::is_screen")]
anchor: OverlayAnchor,
},
SwapOverlay {
dest: NodeId,
@@ -0,0 +1,61 @@
use serde_json::json;
use crate::NodeId;
use crate::proto::*;
const TRIGGER: [f64; 2] = [100.0, 40.0];
const OVERLAY: [f64; 2] = [60.0, 200.0];
fn at(anchor: OverlayAnchor, p: OverlayPosition) -> Option<[f64; 2]> {
anchor.offset(p, TRIGGER, OVERLAY)
}
#[test]
fn outside_sits_above_or_below_with_edges_lined_up() {
use OverlayPosition as P;
let out = OverlayAnchor::TriggerOutside;
assert_eq!(at(out, P::BottomLeft), Some([0.0, 40.0]));
assert_eq!(at(out, P::BottomCenter), Some([20.0, 40.0]));
assert_eq!(at(out, P::BottomRight), Some([40.0, 40.0]));
assert_eq!(at(out, P::TopLeft), Some([0.0, -200.0]));
assert_eq!(at(out, P::TopCenter), Some([20.0, -200.0]));
assert_eq!(at(out, P::TopRight), Some([40.0, -200.0]));
assert_eq!(at(out, P::Center), Some([20.0, -80.0]));
}
#[test]
fn inside_uses_the_presets_on_the_trigger() {
use OverlayPosition as P;
let ins = OverlayAnchor::TriggerInside;
assert_eq!(at(ins, P::TopLeft), Some([0.0, 0.0]));
assert_eq!(at(ins, P::TopRight), Some([40.0, 0.0]));
assert_eq!(at(ins, P::BottomLeft), Some([0.0, -160.0]));
assert_eq!(at(ins, P::BottomCenter), Some([20.0, -160.0]));
assert_eq!(at(ins, P::Center), Some([20.0, -80.0]));
}
#[test]
fn the_screen_and_manual_positions_have_no_preset_offset() {
assert_eq!(at(OverlayAnchor::Screen, OverlayPosition::BottomLeft), None);
assert_eq!(at(OverlayAnchor::TriggerOutside, OverlayPosition::Manual), None);
assert_eq!(at(OverlayAnchor::TriggerInside, OverlayPosition::Manual), None);
}
#[test]
fn json_keeps_the_anchor_and_old_actions_stay_on_the_screen() {
let a = Action::OpenOverlay {
dest: NodeId(7),
position: OverlayPosition::BottomLeft,
close_on_outside: true,
background: None,
offset: None,
anchor: OverlayAnchor::TriggerOutside,
};
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v["anchor"], "triggerOutside");
assert_eq!(serde_json::from_value::<Action>(v).unwrap(), a);
let old: Action = serde_json::from_value(json!({"type": "openOverlay", "dest": 7, "position": "bottomLeft"})).unwrap();
assert!(matches!(old, Action::OpenOverlay { anchor: OverlayAnchor::Screen, .. }));
assert!(serde_json::to_value(&old).unwrap().get("anchor").is_none());
}
+65 -4
View File
@@ -120,23 +120,40 @@ impl TextCase {
}
/// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises
/// the first letter of each word even when a word spans several styled runs).
/// the first letter of each word even when a word spans several styled runs). An apostrophe
/// as `prev` is taken to continue a word.
pub fn apply_after(self, s: &str, prev: Option<char>) -> String {
self.apply_impl(s, prev, prev.is_some_and(is_word_char))
}
/// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe
/// only continues a word when a letter or digit precedes it.
pub fn apply_between(self, s: &str, before: Option<char>, prev: Option<char>) -> String {
let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric)));
self.apply_impl(s, prev, continues)
}
/// `in_word` says whether `prev` continues a word (rather than a new word starting here).
fn apply_impl(self, s: &str, prev: Option<char>, in_word: bool) -> String {
match self {
TextCase::Original | TextCase::SmallCaps => s.to_string(),
TextCase::Upper => s.to_uppercase(),
TextCase::Lower => s.to_lowercase(),
TextCase::Title => {
let mut out = String::with_capacity(s.len());
let mut start = prev.is_none_or(|c| !is_word_char(c));
let mut start = !in_word;
let mut last = prev;
for ch in s.chars() {
if start && ch.is_alphanumeric() {
out.extend(ch.to_uppercase());
start = false;
} else {
out.push(ch);
start = !is_word_char(ch);
// An apostrophe only continues a word right after a letter or digit.
let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric);
start = !is_word_char(ch) || opening_apostrophe;
}
last = Some(ch);
}
out
}
@@ -417,7 +434,8 @@ pub struct TextData {
/// line's baseline).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub leading_trim: LeadingTrim,
/// Max lines when truncating (0 = as many as fit).
/// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many
/// as fit).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub max_lines: u32,
/// OpenType feature toggles (e.g. "liga": false).
@@ -510,6 +528,28 @@ impl TextData {
self.characters.chars().count()
}
/// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height
/// with a line limit (the box hugs up to `max_lines` lines).
pub fn truncates(&self) -> bool {
match self.auto_resize {
TextAutoResize::Truncate => true,
TextAutoResize::Height => self.max_lines > 0,
TextAutoResize::WidthAndHeight | TextAutoResize::None => false,
}
}
/// The resize mode once the box is given an explicit `width` and/or `height`: an explicit
/// height fixes the box, an explicit width turns auto width into auto height, and truncating
/// text keeps truncating.
pub fn fix_size(&mut self, width: bool, height: bool) {
self.auto_resize = match self.auto_resize {
_ if height && self.truncates() => TextAutoResize::Truncate,
_ if height => TextAutoResize::None,
TextAutoResize::WidthAndHeight if width => TextAutoResize::Height,
mode => mode,
};
}
/// The style at char index `i`.
pub fn style_at(&self, i: usize) -> &TextStyle {
self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style)
@@ -857,3 +897,24 @@ mod case_tests {
assert_eq!(t.para_list(0), ParaList::default());
}
}
#[cfg(test)]
mod title_case_tests {
use super::TextCase;
#[test]
fn title_case_after_opening_apostrophe() {
assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'");
assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'");
assert_eq!(TextCase::Title.apply("don't"), "Don't");
assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello");
}
#[test]
fn title_case_between_runs_sees_char_before_apostrophe() {
assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello");
assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T");
}
}
+2
View File
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
figcraft-pdf = { workspace = true }
figcraft-vectordrawable = { workspace = true }
craft-docpath = { workspace = true }
figcraft-plugin = { workspace = true }
serde = { workspace = true }
@@ -28,6 +29,7 @@ thiserror = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }
regex = { workspace = true }
deunicode = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
+7
View File
@@ -0,0 +1,7 @@
pub mod components;
pub mod library;
pub mod library_server;
pub mod library_swap;
pub mod playground;
pub mod style_vars;
pub mod token_roles;
+303
View File
@@ -0,0 +1,303 @@
//! Components and instances.
//!
//! An instance's layers are real nodes copied from its main component; each copy records the
//! main-component node it mirrors (`source`) and which property groups were changed locally
//! (`overrides`). When a main component changes, every instance is rebuilt from it, keeping layer
//! ids and re-applying overrides (matched by source, then by layer name path, so switching
//! variants keeps compatible overrides). Variants are components inside a component set, named
//! `Property=Value, …`; component properties (boolean, text, instance swap) are applied through
//! `prop_refs` on the layers they control.
//!
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
//! inside an instance, `modes` (the variable modes a layer inside an instance chooses),
//! `component` (a swapped nested instance), `props` (property values of a nested instance) and
//! `slot` (a slot holding the instance's own content). An instance's own export settings,
//! interactions, resizing and variable modes are its own, like its place. `position`
//! overrides of layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
//!
//! Switching variants keeps an override where the layer's name path matches and the two variants
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
//! only text layers' overrides ([`keep_text_overrides`]).
//!
//! Instances never gain or lose layers of their own: commands that would add, remove or reorder
//! layers inside an instance are refused ([`check_structure_edit`]), except inside **slots**. A
//! slot is a frame of a main component bound to a slot property (`prop_refs["slot"]`). An
//! instance's copy of it shows the main's default content until its layers are edited; from then
//! on (override group `slot`) its children are the instance's own content: free to edit, kept
//! across syncs and variant switches, and outside the instance's structure (instances placed in it
//! stand alone).
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{
ChildLayout, ContentAlign, CrossAlign, DocError, Document, MainAlign, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData,
};
use figcraft_geom::Affine;
use serde_json::Value;
mod diff;
mod guard;
mod instances;
mod patch;
mod rebase;
mod sync;
mod variants;
pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups};
pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers};
pub(crate) use instances::copy_group;
pub use instances::{PROP_BINDING, copy_in_file, create_instance, create_instance_at, sync_instance};
pub use patch::sync_after_edit;
#[cfg(test)]
pub(crate) use patch::{FULL_REBUILD, PATCHED};
#[cfg(test)]
use rebase::RebaseSources;
pub use rebase::rebase_instance;
pub use sync::keep_text_overrides;
pub use variants::{default_variant, exposed_copies, find_variant, mirror_issues, variant_conflicts, variant_name, variant_options, variant_props};
/// Nesting limit for instances inside instances.
const MAX_DEPTH: usize = 16;
/// The override group (and `prop_refs` field) of slots.
pub const SLOT: &str = "slot";
/// Every override group a layer can carry.
pub const OVERRIDE_GROUPS: &[&str] = &[
"fills",
"strokes",
"effects",
"opacity",
"visible",
"name",
"corners",
"characters",
"textStyle",
"padding",
"gap",
"alignment",
"grids",
"exports",
"interactions",
"size",
"resizing",
"position",
"modes",
"component",
"props",
SLOT,
];
fn is_instance(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }))
}
/// A slot (inside an instance) whose children are the instance's own content.
fn holds_content(n: &Node) -> bool {
n.overrides.contains(SLOT)
}
/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content:
/// content belongs to no instance (it edits freely, and instances placed in it stand alone).
fn scope(doc: &Document, id: NodeId) -> Vec<NodeId> {
let mut out = vec![id];
for a in doc.ancestors(id) {
if doc.node(a).is_some_and(holds_content) {
break;
}
out.push(a);
}
out
}
/// The component a node belongs to (itself, or the nearest component ancestor).
pub fn owning_component(doc: &Document, id: NodeId) -> Option<NodeId> {
let mut chain = vec![id];
chain.extend(doc.ancestors(id));
chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })))
}
/// The innermost instance containing `id` (`id` itself when it is an instance).
pub fn owning_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
scope(doc, id).into_iter().find(|c| is_instance(doc, *c))
}
/// The outermost instance containing `id` (`id` itself when it is a top-level instance).
pub fn root_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c))
}
/// The innermost instance `id` is a layer of (never `id` itself).
fn enclosing_instance(doc: &Document, id: NodeId) -> Option<NodeId> {
scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c))
}
/// Whether `id` is a layer inside an instance (not the outermost instance itself).
pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
enclosing_instance(doc, id).is_some()
}
/// Mark the `modes` override on layers inside instances whose variable modes differ from their
/// main layer's, for files from before `modes` was an override group. Copies follow their main
/// layer's modes, so a layer on other modes chose them itself.
pub fn mark_layer_modes(doc: &mut Document) {
let chose: Vec<NodeId> = doc
.nodes()
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
.map(|n| n.id)
.filter(|id| inside_instance(doc, *id))
.collect();
for id in chose {
if let Some(n) = doc.node_mut(id) {
n.overrides.insert("modes".into());
}
}
}
/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
/// a slot property of its component, or an instance's copy of one (followed through its sources).
pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
let mut cur = id;
for _ in 0..=MAX_DEPTH {
let n = doc.node(cur)?;
if !matches!(n.kind, NodeKind::Frame) {
return None;
}
if let Some(name) = n.prop_refs.get(SLOT) {
let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None };
return match props.get(name) {
Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())),
_ => None,
};
}
cur = n.source?;
}
None
}
/// The name of the slot property frame `id` stands for ([`slot_def`]).
pub fn slot_name(doc: &Document, id: NodeId) -> Option<String> {
slot_def(doc, id).map(|(name, _)| name.to_string())
}
/// Whether `id` is a slot (in a main component, or an instance's copy of one).
pub fn is_slot(doc: &Document, id: NodeId) -> bool {
slot_def(doc, id).is_some()
}
/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's
/// default content.
pub fn slot_edited(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(holds_content)
}
/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and
/// reordered freely.
pub fn slot_scope(doc: &Document, id: NodeId) -> Option<NodeId> {
let mut chain = vec![id];
chain.extend(doc.ancestors(id));
for c in chain {
if is_instance(doc, c) {
return None;
}
if is_slot(doc, c) {
return Some(c);
}
}
None
}
/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an
/// instance, outside its slots.
pub fn structure_locked(doc: &Document, id: NodeId) -> bool {
owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none()
}
/// For tree walks that look for where layers can go: whether the children of `id` are locked (in
/// an instance, outside its slots), given whether `id` itself is (`locked`).
pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool {
is_instance(doc, id) || (locked && !is_slot(doc, id))
}
/// Whether layer `id`'s place (parent and position among its siblings) changed between two
/// versions only where instance structure allows it.
pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool {
let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id)));
let (was, is) = (place(before), place(after));
was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p)))
}
/// The slots of a component or instance (its own: not those of instances nested in it, nor of the
/// content put into its slots), with their property names, top-most first.
pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
let mut seen = 0usize;
while let Some(c) = stack.pop() {
seen += 1;
if seen > 1_000_000 {
break;
}
let Some(n) = doc.node(c) else { continue };
if let Some(name) = slot_name(doc, c) {
out.push((c, name));
}
if !is_instance(doc, c) && !holds_content(n) {
stack.extend(n.children.iter().rev().copied());
}
}
out
}
/// All instances of `component` in the document.
pub fn instances_of(doc: &Document, component: NodeId) -> Vec<NodeId> {
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect()
}
/// The component set a variant belongs to.
pub fn variant_set(doc: &Document, component: NodeId) -> Option<NodeId> {
doc.node(component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. })))
}
/// The layer name a fresh instance of `component` gets: the set's name for variants.
pub fn default_instance_name(doc: &Document, component: NodeId) -> String {
let set = variant_set(doc, component).and_then(|s| doc.node(s));
set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into())
}
/// Default property values of a component (slots hold content, not values: they have none).
pub fn default_props(comp: &Node) -> BTreeMap<String, Value> {
let mut out = BTreeMap::new();
if let NodeKind::Component { props, .. } = &comp.kind {
for (k, d) in props {
if takes_value(d) {
out.insert(k.clone(), prop_default_value(d));
}
}
}
out
}
/// Whether instances store a value for the property (every kind but slots).
pub fn takes_value(d: &PropDef) -> bool {
!matches!(d, PropDef::Slot { .. })
}
/// A property definition's default as a JSON value.
pub fn prop_default_value(d: &PropDef) -> Value {
match d {
PropDef::Boolean { default } => Value::Bool(*default),
PropDef::Text { default } => Value::String(default.clone()),
PropDef::InstanceSwap { default, .. } => Value::from(default.0),
PropDef::Variant { default, .. } => Value::String(default.clone()),
PropDef::Slot { .. } => Value::Null,
}
}
#[cfg(test)]
mod rebase_source_tests;
#[cfg(test)]
pub(crate) mod tests;
+218
View File
@@ -0,0 +1,218 @@
use super::instances::PROP_BINDING;
use super::{Document, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, inside_instance, owning_component, variant_set};
/// The property groups whose value layer `n` takes from the same variables as its main layer
/// `src` (they differ only where the two resolve in different variable modes).
pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> {
let mut out = Vec::new();
for (k, v) in &n.bound {
let Some(g) = binding_group(k) else { continue };
if src.bound.get(k) == Some(v) && !out.contains(&g) {
out.push(g);
}
}
out
}
/// The override group a variable binding key feeds (`None` for component property and layout
/// grid bindings, which follow other rules).
pub(super) fn binding_group(key: &str) -> Option<&'static str> {
use crate::cmd::lists::{BoundList, list_binding};
if key.starts_with(PROP_BINDING) {
return None;
}
match list_binding(key).map(|(l, _, _)| l) {
Some(BoundList::Fills) => Some("fills"),
Some(BoundList::Strokes) => Some("strokes"),
Some(BoundList::Effects) => Some("effects"),
Some(BoundList::Grids) => None,
None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"),
}
}
/// The variable bindings of `n` that feed group `g`.
pub(super) fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator<Item = (&'a String, &'a String)> + 'a {
n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g))
}
/// Property groups that differ between two versions of a node.
pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
let mut out = Vec::new();
if a.fills != b.fills {
out.push("fills");
}
if a.strokes != b.strokes || a.stroke != b.stroke {
out.push("strokes");
}
if a.effects != b.effects {
out.push("effects");
}
if a.opacity != b.opacity || a.blend != b.blend {
out.push("opacity");
}
if a.visible != b.visible {
out.push("visible");
}
if a.name != b.name {
out.push("name");
}
if a.corners != b.corners {
out.push("corners");
}
if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
out.push("size");
}
if resizing(a) != resizing(b) {
out.push("resizing");
}
if a.transform != b.transform {
out.push("position");
}
if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) {
if x.characters != y.characters {
out.push("characters");
}
if text_style_differs(x, y) {
out.push("textStyle");
}
}
if let (Some(x), Some(y)) = (&a.layout, &b.layout) {
if x.padding != y.padding {
out.push("padding");
}
if x.gap != y.gap || x.cross_gap != y.cross_gap {
out.push("gap");
}
if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align {
out.push("alignment");
}
}
if a.grids != b.grids || a.styles.grid != b.styles.grid {
out.push("grids");
}
if a.exports != b.exports {
out.push("exports");
}
if a.interactions != b.interactions {
out.push("interactions");
}
if a.var_modes != b.var_modes {
out.push("modes");
}
if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
if x != y {
out.push("component");
}
if px != py {
out.push("props");
}
}
out
}
/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes.
fn resizing(n: &Node) -> (Sizing, Sizing, [Option<f64>; 4]) {
let c = &n.child_layout;
(c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h])
}
/// Whether two texts differ in anything but their characters (the `textStyle` group).
fn text_style_differs(x: &TextData, y: &TextData) -> bool {
x.style != y.style
|| x.runs != y.runs
|| x.auto_resize != y.auto_resize
|| x.align_h != y.align_h
|| x.align_v != y.align_v
|| x.paragraph_spacing != y.paragraph_spacing
|| x.paragraph_indent != y.paragraph_indent
|| x.list != y.list
|| x.paragraphs != y.paragraphs
|| x.list_spacing != y.list_spacing
|| x.hanging_punctuation != y.hanging_punctuation
|| x.hanging_list != y.hanging_list
|| x.max_lines != y.max_lines
|| x.features != y.features
}
/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was
/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text.
pub(super) fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool {
for a in doc.ancestors(id) {
let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue };
if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 {
return true;
}
}
let Some(n) = doc.node(id) else { return true };
if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute {
return true;
}
if group == "size"
&& let NodeKind::Text(t) = &n.kind
{
let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6);
return match t.auto_resize {
TextAutoResize::WidthAndHeight => true,
TextAutoResize::Height => !wchanged,
_ => false,
};
}
false
}
/// The components sharing the properties of component `c`: the variants of its set, or `c` alone.
pub fn property_owners(doc: &Document, c: NodeId) -> Vec<NodeId> {
match variant_set(doc, c).and_then(|s| doc.node(s)) {
Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(),
None => vec![c],
}
}
/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding
/// their layers bound to it: on a main component, a boolean property's value is the visibility of
/// its layers. Returns the components and layers it changed.
pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec<NodeId> {
let mut out = Vec::new();
for &c in comps {
let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue };
let Some(PropDef::Boolean { default }) = props.get(name) else { continue };
if *default != value
&& let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind)
{
props.insert(name.to_string(), PropDef::Boolean { default: value });
out.push(c);
}
// The main's own layers (not those of instances nested in it, which follow their mains).
let layers: Vec<NodeId> = doc
.subtree(c)
.into_iter()
.filter(|d| *d != c && !inside_instance(doc, *d))
.filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name)))
.collect();
for d in layers {
if let Some(n) = doc.node_mut(d) {
n.visible = value;
out.push(d);
}
}
}
out
}
/// A main component's layer bound to a boolean property was shown or hidden: that is the
/// property's new default, which its other bound layers (in every variant) take too. Returns the
/// components and layers that changed as a result.
pub(super) fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec<NodeId> {
let mut out = Vec::new();
for &id in changed {
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
if n.visible == b.visible || inside_instance(doc, id) {
continue;
}
let Some(name) = n.prop_refs.get("visible").cloned() else { continue };
let visible = n.visible;
let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue };
out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible));
}
out
}
@@ -0,0 +1,138 @@
use super::diff::geometry_derived;
use super::rebase::rebase_instance;
use super::variants::instance_issues;
use super::{
ContentAlign, CrossAlign, Document, MainAlign, Node, NodeId, NodeKind, SLOT, Sizing, enclosing_instance, inside_instance, is_instance,
owning_instance, structure_locked,
};
/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the
/// main's default content until now) stop mirroring the main, and instances among them stand
/// alone, linked to their own mains.
pub(super) fn own_slot_content(doc: &mut Document, slot: NodeId) {
if let Some(n) = doc.node_mut(slot) {
n.overrides.insert(SLOT.into());
}
for nested in unlink_layers(doc, slot, None) {
rebase_instance(doc, nested);
}
}
/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the
/// content of a slot): plain layers forget the main layer they mirrored and their property
/// bindings. The instances among them are returned, to re-link to their own mains (with
/// [`rebase_instance`]) once the caller is done.
pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option<NodeId>) -> Vec<NodeId> {
let picked: Vec<(NodeId, bool)> =
doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect();
let mut nested = Vec::new();
for (d, instance) in picked {
let Some(n) = doc.node_mut(d) else { continue };
n.prop_refs.clear();
if instance {
nested.push(d);
} else {
n.source = None;
n.overrides.clear();
}
}
nested
}
/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it
/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept).
pub(super) fn relink_copy(doc: &mut Document, id: NodeId) {
let Some(n) = doc.node(id) else { return };
match &n.kind {
NodeKind::Instance { component, .. } => {
let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. }));
if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) {
rebase_instance(doc, id);
}
}
_ => {
if (n.source.is_some() || !n.overrides.is_empty())
&& owning_instance(doc, id).is_none()
&& let Some(m) = doc.node_mut(id)
{
m.source = None;
m.overrides.clear();
}
}
}
}
/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside
/// its slots.
pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> {
for &id in after.dirty() {
let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue };
// Library copies change only through library updates (and are read-only otherwise).
if n.children == b.children || after.is_remote(id) {
continue;
}
if structure_locked(before, id) && structure_locked(after, id) {
let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default();
return Err(format!(
"layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first"
));
}
}
Ok(())
}
/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled
/// back.
pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> {
match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) {
Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")),
None => Ok(()),
}
}
/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer
/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also
/// when the main changed the same way in this edit (a library update).
pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
let (n, b) = (after.node(id)?, before.node(id)?);
if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
return None;
}
let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?);
let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode);
// Auto layout settings other than the overridable ones.
let settings = |x: &Node| {
x.layout.clone().map(|mut l| {
(l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min);
l.content_align = ContentAlign::Auto;
l
})
};
// Child layout settings other than the resizing ones.
let placing = |x: &Node| {
let mut c = x.child_layout.clone();
(c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None);
c
};
if mode(n) != mode(b) && mode(ma) == mode(mb) {
return Some("The auto layout direction");
}
if settings(n) != settings(b) && settings(ma) == settings(mb) {
return Some("This auto layout setting");
}
if !inside_instance(after, id) || !inside_instance(before, id) {
return None;
}
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
return Some("A layer's position");
}
if n.constraints != b.constraints && ma.constraints == mb.constraints {
return Some("A layer's constraints");
}
if placing(n) != placing(b) && placing(ma) == placing(mb) {
return Some("A layer's place in auto layout");
}
None
}
@@ -0,0 +1,345 @@
use super::diff::{binding_group, bindings_of};
use super::sync::{insert_linked, sync_instance_depth};
use super::{
Affine, BTreeMap, ChildLayout, DocError, Document, Node, NodeId, NodeKind, Value, default_instance_name, default_props, holds_content, is_slot,
slot_edited,
};
/// Create an instance of `component` under `parent` at `(x, y)` (parent space).
pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result<NodeId, DocError> {
create_instance_at(doc, component, parent, x, y, None)
}
/// [`create_instance`] at child `index` of `parent` (None: on top).
pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option<usize>) -> Result<NodeId, DocError> {
let comp = doc.get(component)?.clone();
if !matches!(comp.kind, NodeKind::Component { .. }) {
return Err(DocError::Invalid(format!("{component} is not a component")));
}
if doc.is_ancestor(component, parent) {
return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
}
let id = doc.alloc_id();
let mut root = comp.clone();
root.id = id;
root.name = default_instance_name(doc, component);
root.kind = NodeKind::Instance { component, props: default_props(&comp) };
root.children.clear();
root.parent = None;
root.source = Some(component);
root.overrides.clear();
root.transform = Affine::translate((x, y));
root.prop_refs.clear();
root.annotations.clear();
root.code_links.clear();
// The main's dev resources show on its instances without being copied.
root.dev_resources.clear();
// Measurements belong to the layer they were placed on.
root.measurements.clear();
// The main's interactions play on its instances in prototypes; the instance's own start empty.
root.interactions.clear();
// So do the variable modes the main sets (see `Document::inherited_modes`).
root.var_modes.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)
}
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
/// has one main per component, so a main component's copy is an instance of it; a variant copied
/// into its set stays a new variant, and every other layer is deep-copied.
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
let n = doc.get(id)?;
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
return doc.duplicate_into(id, parent, index);
}
let transform = n.transform;
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
doc.get_mut(instance)?.transform = transform;
Ok(instance)
}
/// Which part of the carried overrides to apply.
#[derive(Clone, Copy, PartialEq)]
pub(super) enum Carry {
/// Paint, text, visibility, … (everything but geometry).
Values,
/// Values plus size (instance roots: their size decides how children follow constraints).
Root,
/// Size and position (children, after constraints ran).
Geometry,
}
/// Copy the overridden property groups of `from` onto `to`.
pub(super) fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
for g in &from.overrides {
let geo = matches!(g.as_str(), "size" | "position");
match what {
Carry::Values if geo => continue,
Carry::Root if g == "position" => continue,
Carry::Geometry if !geo => continue,
_ => {}
}
copy_group(from, to, g);
}
if what != Carry::Geometry {
to.overrides = from.overrides.clone();
}
}
/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis)
/// that is still `was`'s follows `new`; what the instance set itself stays in `own`.
pub(super) fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) {
if own.sizing_h == was.sizing_h {
own.sizing_h = new.sizing_h;
}
if own.sizing_v == was.sizing_v {
own.sizing_v = new.sizing_v;
}
for (o, w, n) in [
(&mut own.min_w, was.min_w, new.min_w),
(&mut own.max_w, was.max_w, new.max_w),
(&mut own.min_h, was.min_h, new.min_h),
(&mut own.max_h, was.max_h, new.max_h),
] {
if *o == w {
*o = n;
}
}
}
/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them.
pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) {
to.bound.retain(|k, _| binding_group(k) != Some(g));
to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone())));
}
match g {
"fills" => {
to.fills = from.fills.clone();
to.styles.fill = from.styles.fill.clone();
}
"strokes" => {
to.strokes = from.strokes.clone();
to.stroke = from.stroke.clone();
to.styles.stroke = from.styles.stroke.clone();
}
"effects" => {
to.effects = from.effects.clone();
to.styles.effect = from.styles.effect.clone();
}
"opacity" => {
to.opacity = from.opacity;
to.blend = from.blend;
}
"visible" => to.visible = from.visible,
"name" => to.name = from.name.clone(),
"corners" => to.corners = from.corners,
"characters" => {
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.characters = a.characters.clone();
b.runs = a.runs.clone();
b.paragraphs = a.paragraphs.clone();
}
}
"textStyle" => {
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.style = a.style.clone();
b.runs = a.runs.clone();
b.auto_resize = a.auto_resize;
b.align_h = a.align_h;
b.align_v = a.align_v;
b.paragraph_spacing = a.paragraph_spacing;
b.paragraph_indent = a.paragraph_indent;
b.list = a.list;
b.paragraphs = a.paragraphs.clone();
b.list_spacing = a.list_spacing;
b.hanging_punctuation = a.hanging_punctuation;
b.hanging_list = a.hanging_list;
b.max_lines = a.max_lines;
b.features = a.features.clone();
}
to.styles.text = from.styles.text.clone();
}
"padding" | "gap" | "alignment" => {
if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) {
match g {
"padding" => b.padding = a.padding,
"gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap),
_ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align),
}
}
}
"grids" => {
to.grids = from.grids.clone();
to.styles.grid = from.styles.grid.clone();
}
"exports" => to.exports = from.exports.clone(),
"interactions" => to.interactions = from.interactions.clone(),
"size" => {
to.width = from.width;
to.height = from.height;
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.auto_resize = a.auto_resize;
}
}
"resizing" => {
let (a, b) = (&from.child_layout, &mut to.child_layout);
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
}
"position" => to.transform = from.transform,
"modes" => to.var_modes = from.var_modes.clone(),
"component" => {
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
*b = *a;
}
}
"props" => {
if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) {
*b = a.clone();
}
carry_prop_bindings(from, to);
}
_ => {}
}
}
/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With
/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames
/// don't break matching), and the content of slots holding the instance's own is left out.
pub(super) fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap<NodeId, String> {
let mut out = BTreeMap::new();
let mut stack = vec![(root, String::new())];
while let Some((id, prefix)) = stack.pop() {
let Some(n) = doc.node(id) else { continue };
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
for c in &n.children {
let Some(cn) = doc.node(*c) else { continue };
// A nested instance linked only to its own component so far (an imported one) mirrors no
// layer yet: it goes by its own name, like the main's layer it will be matched with.
let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component));
let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s));
let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str());
let k = seen.entry(name).or_insert(0);
let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") };
*k += 1;
let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") };
out.insert(*c, path.clone());
if out.len() > 1_000_000 {
return out;
}
if !(src_names && holds_content(cn)) {
stack.push((*c, path));
}
}
}
out
}
/// The binding key prefix of an instance's component property value
/// (`componentProperties/<name>`): a boolean property takes a boolean variable, a text property a
/// string variable.
pub const PROP_BINDING: &str = "componentProperties/";
/// Give `to` the component property bindings of instance `from` (they go with its property
/// values, which an instance keeps across rebuilds).
pub(super) fn carry_prop_bindings(from: &Node, to: &mut Node) {
to.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone())));
}
/// The override group a property-bound field belongs to.
pub(super) fn field_group(field: &str) -> &'static str {
match field {
"visible" => "visible",
"characters" => "characters",
"mainComponent" => "component",
_ => "",
}
}
/// Apply one component-property value to a layer.
pub(super) fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
match field {
"visible" => {
if let Some(b) = val.as_bool() {
n.visible = b;
}
}
"characters" => {
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
&& t.characters != sv
{
let len = t.char_count();
t.replace_range(0, len, &sv);
}
}
"mainComponent" => {
if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val))
&& doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. }))
{
*c = v;
}
}
_ => {}
}
}
/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its
/// slots. An instance whose main component was deleted keeps its layers as they are.
pub fn sync_instance(doc: &mut Document, inst: NodeId) {
// Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits).
let old: Vec<(std::sync::Arc<Node>, bool)> =
doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect();
let kept = slot_content(doc, inst);
sync_instance_depth(doc, inst, 0, BTreeMap::new());
restore_slot_content(doc, kept);
for (a, was_dirty) in old {
doc.keep_if_unchanged(a, was_dirty);
}
}
/// The slots in instance `inst` (nested instances included) holding the instance's own content:
/// each with its content (the subtrees of its children, pre-order).
fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec<Node>)> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default();
while let Some(c) = stack.pop() {
let Some(n) = doc.node(c) else { continue };
if holds_content(n) {
let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect();
out.push((c, nodes));
} else {
stack.extend(n.children.iter().copied());
}
}
out
}
/// Put slot content back after a rebuild, into the slots that still hold the instance's own
/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints
/// and geometry overrides ran before, so the content keeps its place.
fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec<Node>)>) {
for (slot, nodes) in kept {
let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id));
if !keep {
continue;
}
let ids: Vec<NodeId> = nodes.iter().map(|n| n.id).collect();
for n in nodes {
// Pre-order: every parent is back before its children.
let _ = insert_linked(doc, n);
}
// Never leave links to a layer that couldn't come back.
for id in ids.into_iter().chain([slot]) {
let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c)));
if dangling {
let live: Vec<NodeId> = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default();
if let Some(n) = doc.node_mut(id) {
n.children = live;
}
}
}
}
}
@@ -0,0 +1,548 @@
use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaults, geometry_derived, variable_groups};
use super::guard::{own_slot_content, relink_copy};
use super::instances::{copy_group, field_group, sync_instance};
use super::sync::insert_linked;
use super::{
BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content, inside_instance, owning_component,
owning_instance, root_instance, scope, slot_edited, slot_scope,
};
type OverrideGroups = (Vec<&'static str>, Vec<&'static str>, Vec<&'static str>);
/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
/// whose properties or main were switched, or whose overrides were reset.
pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet<NodeId>) {
let mut changed = changed.to_vec();
for id in follow_boolean_defaults(doc, before, &changed) {
if !changed.contains(&id) {
changed.push(id);
}
}
let changed = changed.as_slice();
adopt_slot_content(doc, before, changed);
let mut resync: BTreeSet<NodeId> = BTreeSet::new();
record_overrides(doc, before, changed, &mut resync);
// 2. Components whose layers changed, were added or removed. Value-only edits (paint, text,
// visibility, …) are carried to the copies directly; anything else rebuilds the instances.
let mut comps: BTreeSet<NodeId> = BTreeSet::new();
let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new();
// Main layers that only gained new layers or lost some: their copies take the same change.
let mut restructured: Vec<NodeId> = Vec::new();
let mut covered: BTreeSet<NodeId> = BTreeSet::new();
for &id in changed {
if let (Some(b), Some(n)) = (before.node(id), doc.node(id))
&& b.children != n.children
&& owning_instance(doc, id).is_none()
&& owning_component(doc, id).is_some()
&& let Some(layers) = restructurable(doc, before, b, n)
{
restructured.push(id);
covered.extend(layers);
}
}
for &id in changed {
if covered.contains(&id) || restructured.contains(&id) {
continue;
}
let Some(c) = owning_component(doc, id) else { continue };
let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else {
comps.insert(c);
continue;
};
if b == n {
continue;
}
match patchable(doc, b, n) {
Some(groups) => patches.push((id, groups)),
None => {
comps.insert(c);
}
}
}
for &id in touched {
if !doc.contains(id)
&& !covered.contains(&id)
&& let Some(c) = owning_component(before, id)
&& doc.contains(c)
{
comps.insert(c);
}
}
for r in resync {
if doc.contains(r) {
sync_instance(doc, r);
if let Some(c) = owning_component(doc, r) {
comps.insert(c);
}
}
}
// A copy the patch can't follow exactly rebuilds instead (as does any other change).
let patched = comps.is_empty()
&& !full_rebuild()
&& (restructured.is_empty() || patch_structure(doc, before, &restructured))
&& (patches.is_empty() || patch_copies(doc, &patches));
#[cfg(test)]
if patched && (!restructured.is_empty() || !patches.is_empty()) {
PATCHED.with(|c| c.set(c.get() + 1));
}
if !patched {
comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id)));
}
// 3. Instances of changed components follow; an instance inside another component makes that
// one change too.
rebuild_instances(doc, &comps);
}
/// Slot content changes: layers added, removed or reordered in a slot become the instance's own.
fn adopt_slot_content(doc: &mut Document, before: &Document, changed: &[NodeId]) {
// 0. A slot whose layers were added, removed or reordered holds the instance's own content
// from now on; copies of instance layers placed elsewhere stop mirroring their main.
for &id in changed {
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
if n.children == b.children || owning_instance(doc, id).is_none() {
continue;
}
if let Some(slot) = slot_scope(doc, id)
&& !slot_edited(doc, slot)
{
own_slot_content(doc, slot);
}
}
for &id in changed {
if !before.contains(id) {
relink_copy(doc, id);
}
}
}
/// Overrides for layers edited inside instances; the instances to rebuild go into `resync`.
fn record_overrides(doc: &mut Document, before: &Document, changed: &[NodeId], resync: &mut BTreeSet<NodeId>) {
for &id in changed {
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
let Some(inst) = owning_instance(doc, id) else { continue };
let top_root = inst == id && !inside_instance(doc, id);
if top_root
&& let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
&& (c1 != c2 || p1 != p2)
{
// Switching the main or a property value of an instance isn't an override.
resync.insert(id);
}
let (raw, groups, cleared) = override_groups(doc, before, id, n, b, inst, top_root);
let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
if (!groups.is_empty() || !cleared.is_empty())
&& let Some(m) = doc.node_mut(id)
{
for g in groups {
m.overrides.insert(g.to_string());
}
for g in cleared {
m.overrides.remove(g);
}
}
if (lost || nested_switch)
&& let Some(r) = root_instance(doc, id)
{
resync.insert(r);
}
}
}
/// `(raw, groups, cleared)` of layer `id` edited from `b` to `n`: the raw changed groups, the ones
/// that are now overrides and the overrides that were set back to the main's value.
fn override_groups(doc: &Document, before: &Document, id: NodeId, n: &Node, b: &Node, inst: NodeId, top_root: bool) -> OverrideGroups {
let raw = diff_groups(b, n);
let mut groups = raw.clone();
// An instance's own place, resizing, export settings, interactions and variable modes are
// its own, not overrides.
if top_root {
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
}
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
}
// A value equal to the main's isn't an override (setting it back clears the override).
let mut cleared = Vec::new();
if let Some(src) = n.source.and_then(|s| doc.node(s)) {
let mut differs = diff_groups(src, n);
if inst == id {
differs.retain(|g| *g != "name");
if n.name != default_instance_name(doc, src.id) {
differs.push("name");
}
}
// A value from the same variable as the main's (resolved in this layer's modes) isn't an
// override either.
let from_variables = variable_groups(n, src);
groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
// Binding another variable (or none) where the main binds one is an override.
for (k, _) in b.bound.iter().chain(n.bound.iter()) {
let Some(g) = binding_group(k) else { continue };
let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
if rebound && !groups.contains(&g) {
groups.push(g);
}
}
cleared = raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
if top_root {
cleared.retain(|g| !matches!(*g, "component" | "props"));
}
}
(raw, groups, cleared)
}
#[cfg(test)]
thread_local! {
/// Tests: rebuild instances even for edits that could be patched (to compare both ways).
pub(crate) static FULL_REBUILD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Tests: edits carried to the copies without rebuilding instances.
pub(crate) static PATCHED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn full_rebuild() -> bool {
FULL_REBUILD.with(std::cell::Cell::get)
}
#[cfg(not(test))]
fn full_rebuild() -> bool {
false
}
/// The property groups of a main-component layer edited from `b` to `n` when the edit changed
/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without
/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed.
fn patchable(doc: &Document, b: &Node, n: &Node) -> Option<Vec<&'static str>> {
const VALUES: [&str; 17] = [
"fills",
"strokes",
"effects",
"opacity",
"visible",
"name",
"corners",
"characters",
"textStyle",
"padding",
"gap",
"alignment",
"grids",
"exports",
"interactions",
"size",
"position",
];
if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() {
return None;
}
let groups = diff_groups(b, n);
if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) {
return None;
}
// Exactly those groups (and the layer's own overrides) changed.
let mut probe = n.clone();
for g in &groups {
copy_group(b, &mut probe, g);
}
probe.overrides = b.overrides.clone();
if probe != *b {
return None;
}
// Values a component property sets (on this layer or a main it mirrors) come from instances.
let mut cur = Some(n);
for _ in 0..=MAX_DEPTH {
let Some(x) = cur else { break };
if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) {
return None;
}
cur = x.source.and_then(|s| doc.node(s));
}
Some(groups)
}
/// Copies by the layer they mirror; nested instances swapped to a main also follow it.
fn copies_index(doc: &Document) -> BTreeMap<NodeId, Vec<NodeId>> {
let mut copies: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
for n in doc.nodes() {
let Some(s) = n.source else { continue };
copies.entry(s).or_default().push(n.id);
if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind))
&& component != other
{
copies.entry(*component).or_default().push(n.id);
}
}
copies
}
/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances,
/// components or property bindings in them) or lost layers that were deleted: those layers, which
/// [`patch_structure`] carries to the copies. `None` for anything else.
fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option<Vec<NodeId>> {
let mut probe = n.clone();
probe.children = b.children.clone();
probe.overrides = b.overrides.clone();
if probe != *b {
return None;
}
let mut layers = Vec::new();
for c in n.children.iter().filter(|c| !b.children.contains(c)) {
for d in doc.subtree(*c) {
let x = doc.node(d)?;
let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. });
if before.contains(d) || !plain || !x.prop_refs.is_empty() {
return None;
}
layers.push(d);
}
}
for c in b.children.iter().filter(|c| !n.children.contains(c)) {
if doc.contains(*c) {
return None;
}
layers.extend(before.subtree(*c));
}
Some(layers)
}
/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy,
/// through copies of copies (slots holding an instance's own content keep it). Returns false
/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly.
fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool {
let copies = copies_index(doc);
let mut work: Vec<NodeId> = seeds.to_vec();
let mut steps = 0usize;
while let Some(p) = work.pop() {
steps += 1;
if steps > 10_000_000 {
return false;
}
let Some(pn) = doc.node(p).cloned() else { continue };
for c in copies.get(&p).cloned().unwrap_or_default() {
let Some(cn) = doc.node(c).cloned() else { continue };
if holds_content(&cn) {
continue;
}
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind)
&& a != b
{
continue;
}
// The copy's layers by the layer of `p` they mirror.
let mut mirrors: BTreeMap<NodeId, NodeId> = BTreeMap::new();
for k in &cn.children {
let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false };
if mirrors.insert(src, *k).is_some() {
return false;
}
}
let mut order = Vec::with_capacity(pn.children.len());
for pc in &pn.children {
if let Some(k) = mirrors.remove(pc) {
order.push(k);
continue;
}
// A layer the copy lacks must be new in this edit, placed as in the main.
let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute);
if before.contains(*pc) || !child_follows_main(doc, c, absolute) {
return false;
}
match copy_layers(doc, *pc, c) {
Some(k) => order.push(k),
None => return false,
}
}
// Copies of layers the main lost.
for (src, k) in mirrors {
if doc.contains(src) || doc.remove(k).is_err() {
return false;
}
}
if doc.node(c).is_some_and(|x| x.children != order)
&& let Some(m) = doc.node_mut(c)
{
m.children = order;
}
work.push(c);
}
}
true
}
/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's
/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]).
fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
let Some(p) = doc.node(parent) else { return false };
let constrains = p.layout.is_none() || absolute;
let resized = sized_own(p)
|| match &p.kind {
NodeKind::Instance { component, .. } => {
doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
}
_ => false,
};
!(constrains && resized) && follows_main_geometry(doc, parent)
}
/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the
/// new copy (the caller places it among `parent`'s layers).
fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option<NodeId> {
let sub = doc.subtree(src);
let map: BTreeMap<NodeId, NodeId> = sub.iter().map(|d| (*d, doc.alloc_id())).collect();
for d in &sub {
let mut n = doc.node(*d)?.clone();
n.id = *map.get(d)?;
n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) };
n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect();
n.source = Some(*d);
n.overrides.clear();
n.prop_refs.clear();
n.annotations.clear();
n.measurements.clear();
insert_linked(doc, n).ok()?;
}
map.get(&src).copied()
}
/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through
/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial
/// patch for the caller to rebuild over) when a copy can't follow exactly.
fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool {
let copies = copies_index(doc);
let text = |g: &str| matches!(g, "characters" | "textStyle");
let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec();
let mut steps = 0usize;
while let Some((p, groups)) = work.pop() {
steps += 1;
if steps > 10_000_000 {
return false;
}
let Some(pn) = doc.node(p).cloned() else { continue };
for c in copies.get(&p).cloned().unwrap_or_default() {
let Some(cn) = doc.node(c) else { continue };
// A swapped nested instance shows its own main, not the layer it stands in for.
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind)
&& a != b
{
continue;
}
// An instance keeps its own visibility, place, export settings and interactions; its
// name follows the main's unless renamed; a resized one lays its layers out against
// the main's size.
let root = matches!(pn.kind, NodeKind::Component { .. });
if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) {
return false;
}
let gs: Vec<&'static str> = groups
.iter()
.copied()
.filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.collect();
if gs.is_empty() {
continue;
}
let geometry = gs.iter().any(|g| matches!(*g, "size" | "position"));
if geometry && !follows_main_geometry(doc, c) {
return false;
}
// Text content and style share their runs: a copy overriding one keeps its runs when the
// other follows the main (as a rebuild carries them).
let runs = match &cn.kind {
NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()),
_ => None,
};
if let Some(m) = doc.node_mut(c) {
for g in &gs {
copy_group(&pn, m, g);
}
if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) {
t.runs = r;
}
}
if (geometry || gs.iter().any(|g| text(g)))
&& let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size)
&& doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6)
{
figcraft_layout::set_size(doc, c, w, h);
}
work.push((c, gs));
}
}
true
}
/// Whether a copy's size and place are its main layer's: no layer around it inside its instances
/// applies constraints on the way down to it (a resized instance or frame without auto layout, or
/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest.
fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
let chain = scope(doc, id);
chain.windows(2).all(|w| {
let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
let constrains = a.layout.is_none() || below.child_layout.absolute;
if sized_own(a) && constrains {
return false;
}
match &a.kind {
NodeKind::Instance { component, .. } => {
!constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9)
}
_ => true,
}
})
}
/// Whether a layer inside an instance overrides its size or resizing: it may not have its main
/// layer's size, so the layers in it can't take their main layers' places as they are.
fn sized_own(n: &Node) -> bool {
n.overrides.contains("size") || n.overrides.contains("resizing")
}
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
/// outwards), each outermost instance once, inner components first.
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
if comps.is_empty() {
return;
}
// Outermost instances by the components their layers instantiate.
let mut roots_of: BTreeMap<NodeId, BTreeSet<NodeId>> = BTreeMap::new();
for n in doc.nodes() {
if let NodeKind::Instance { component, .. } = &n.kind {
roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id));
}
}
// Affected components, with how deep their dependencies go (an instance inside component X of
// component Y: X follows Y).
let mut level: BTreeMap<NodeId, usize> = comps.iter().map(|c| (*c, 0)).collect();
let mut outer: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
let mut frontier: Vec<NodeId> = comps.iter().copied().collect();
let mut rounds = 0;
while !frontier.is_empty() && rounds < 64 {
rounds += 1;
let mut next = Vec::new();
for c in frontier {
let lc = level.get(&c).copied().unwrap_or(0);
for r in roots_of.get(&c).into_iter().flatten() {
let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r));
if let Some(x) = o
&& x != c
{
let lx = level.entry(x).or_insert(0);
if *lx < lc + 1 {
*lx = lc + 1;
next.push(x);
}
}
}
}
frontier = next;
}
// Instances inside components by level, then the rest (each once).
let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect();
order.sort();
for (_, r) in order {
if doc.contains(r) {
sync_instance(doc, r);
}
}
}
@@ -0,0 +1,249 @@
use super::diff::{diff_groups, variable_groups};
use super::instances::{field_group, path_map};
use super::{BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content};
/// Bounded source and parent lookups while a rebase reads an unchanged document.
pub(super) struct RebaseSources<'a> {
doc: &'a Document,
chains: BTreeMap<NodeId, Option<Vec<NodeId>>>,
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
main_changes: BTreeMap<NodeId, bool>,
}
impl<'a> RebaseSources<'a> {
const PARENTS: usize = 512;
const CACHE: usize = 8192;
pub(super) fn new(doc: &'a Document) -> Self {
Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() }
}
fn chain(&mut self, source: NodeId) -> Option<Vec<NodeId>> {
if let Some(chain) = self.chains.get(&source) {
return chain.clone();
}
let read = || {
let mut chain = Vec::new();
let mut seen = BTreeSet::new();
let mut current = Some(source);
for _ in 0..MAX_DEPTH {
let Some(id) = current else { return Some(chain) };
if !seen.insert(id) {
return None;
}
let n = self.doc.node(id)?;
chain.push(id);
current = n.source;
}
current.is_none().then_some(chain)
};
let chain = read();
if self.chains.len() < Self::CACHE {
self.chains.insert(source, chain.clone());
}
chain
}
/// Validate the complete parent chain, even if `main` was found before a cycle.
fn in_main(&mut self, id: NodeId, main: NodeId) -> Option<bool> {
if let Some(found) = self.parents.get(&(id, main)) {
return *found;
}
let read = || {
let mut current = Some(id);
let mut seen = BTreeSet::new();
let mut found = false;
for _ in 0..Self::PARENTS {
let Some(id) = current else { return Some(found) };
if !seen.insert(id) {
return None;
}
found |= id == main;
current = self.doc.node(id)?.parent;
}
current.is_none().then_some(found)
};
let found = read();
if self.parents.len() < Self::CACHE {
self.parents.insert((id, main), found);
}
found
}
fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option<NodeId> {
let chain = self.chain(source)?;
if chain.contains(&target) {
return None;
}
let mut member = None;
for id in chain {
if self.in_main(id, main)? && id != main && member.is_none() {
member = Some(id);
}
}
member
}
/// Nearest enclosing instances and their current mains. Slots with own content end scope.
pub(super) fn owners(&self, id: NodeId) -> Option<Vec<(NodeId, NodeId)>> {
let mut owners = Vec::new();
let mut current = self.doc.node(id)?.parent;
let mut seen = BTreeSet::from([id]);
for _ in 0..Self::PARENTS {
let Some(id) = current else { return Some(owners) };
if !seen.insert(id) {
return None;
}
let n = self.doc.node(id)?;
if holds_content(n) {
return Some(owners);
}
if let NodeKind::Instance { component, .. } = &n.kind {
owners.push((id, *component));
}
current = n.parent;
}
current.is_none().then_some(owners)
}
fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main)
}
/// A contextual child belongs to the same placed slot as its enclosing instance.
fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
let Some((owner, main)) = owners.first() else { return false };
if self.member(source, *main, target).is_none() {
return false;
}
if self.in_main(source, *main) == Some(true) {
return true;
}
let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false };
matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main)
&& self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner)
}
fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option<NodeId> {
let source = self.doc.node(id)?.source?;
if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame)
|| !self.valid(source, id, owners)
|| !self.contextual(source, owners)
{
return None;
}
Some(source)
}
fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool {
for (index, (id, main)) in owners.iter().enumerate() {
if *id == stop {
break;
}
let changed = if let Some(changed) = self.main_changes.get(id) {
*changed
} else {
let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => component != main,
Some(NodeKind::Frame) => true,
_ => false,
});
if self.main_changes.len() < Self::CACHE {
self.main_changes.insert(*id, changed);
}
changed
};
if changed {
return true;
}
}
false
}
}
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
/// the look is kept exactly. Values a component property sets aren't overrides.
pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return };
let mine = path_map(doc, inst, true);
let theirs: BTreeMap<String, NodeId> =
if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() };
let value_groups = |a: &Node, b: &Node| -> Vec<String> {
diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
};
let mut updates = Vec::new();
let mut sources = RebaseSources::new(doc);
let root_owners = sources.owners(inst).unwrap_or_default();
let placed_source = sources.placed(inst, &root_owners);
let mut contexts = vec![(inst, component)];
contexts.extend_from_slice(&root_owners);
for (id, path) in &mine {
let Some(n) = doc.node(*id) else { continue };
let owners = sources.owners(*id).unwrap_or_default();
let nearest_main = owners.first().map(|(_, main)| *main);
let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners));
let mut main = valid_source.and_then(|source| sources.member(source, component, *id));
// A swapped or Frame-to-Instance subtree is compared to its current main by the inner
// rebase. An outer name path must not rebind those children to the old expanded copies.
if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) {
updates.push((*id, valid_source, n.overrides.clone()));
continue;
}
main = main
.or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some())));
let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main);
let mut overrides: BTreeSet<String> = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
if let Some(m) = source.and_then(|source| doc.node(source)) {
let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
bound.extend(variable_groups(n, m));
overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) {
overrides.insert("component".into());
}
}
updates.push((*id, source, overrides));
}
// A placed nested root compares to its actual slot. A detached root compares to its own main.
let root_base = placed_source.or_else(|| {
let chain = sources.chain(component)?;
(!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component)
});
let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) {
(Some(m), Some(n)) => {
let from_variables = variable_groups(n, m);
let mut groups: Vec<String> = value_groups(m, n)
.into_iter()
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
.filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
.collect();
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
if named {
groups.push("name".into());
}
if placed_source.is_some()
&& matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b)
{
groups.push("component".into());
}
groups
}
_ => Vec::new(),
};
drop(sources);
for (id, main, overrides) in updates {
if let Some(n) = doc.node_mut(id) {
n.source = main;
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
}
}
if let Some(n) = doc.node_mut(inst) {
n.source = root_base;
// A top-level instance's resizing and modes are its own (a nested one's override its main
// layer's).
n.overrides.retain(|g| {
!matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
});
n.overrides.extend(root_groups);
}
}
+505
View File
@@ -0,0 +1,505 @@
use super::diff::diff_groups;
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
use super::{
Affine, BTreeMap, BTreeSet, DocError, Document, MAX_DEPTH, Node, NodeId, NodeKind, SLOT, Sizing, Value, default_instance_name, default_props,
holds_content, inside_instance, is_slot, owning_component, variant_set,
};
/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested
/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides.
pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap<NodeId, std::sync::Arc<Node>>) {
if depth > MAX_DEPTH {
return;
}
let Some(mut root) = doc.node(inst).cloned() else { return };
let NodeKind::Instance { component, props: root_props } = &root.kind else { return };
let component = *component;
let Some(comp) = doc.node(component).cloned() else { return };
if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) {
// A missing main, or an instance inside its own main (would recurse forever).
return;
}
let eff_props = effective_props(&comp, root_props);
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
// one layer mirrors it) and, only when those don't match, by name path.
let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
let mut by_path: Option<BTreeMap<String, NodeId>> = None;
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
// parent keeps its id and overrides).
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
map.insert(component, inst);
let mut used: BTreeSet<NodeId> = BTreeSet::new();
// Slots holding the instance's own content (new ids), and main layers not copied: the
// default content of those slots.
let mut edited: BTreeSet<NodeId> = BTreeSet::new();
let mut skipped: BTreeSet<NodeId> = BTreeSet::new();
let mut fresh_ids = Vec::new();
for s in &src {
let Some(sn) = doc.node(*s) else { continue };
if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
skipped.insert(*s);
continue;
}
let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
if found.is_none() {
let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false));
found = mains.get(s).and_then(|p| paths.get(p)).copied();
}
let nid = match found {
Some(o) if used.insert(o) => o,
_ => {
fresh_ids.push(*s);
continue;
}
};
if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(m));
}
if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
edited.insert(nid);
}
map.insert(*s, nid);
}
for c in root.children.clone() {
let _ = doc.remove(c);
}
for s in fresh_ids {
let nid = doc.alloc_id();
map.insert(s, nid);
}
// Build the copies (pre-order: parents first).
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
enclosing.insert(inst, vec![inst]);
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::new();
inst_props.insert(inst, eff_props);
let mut nested = Vec::new();
let mut new_nodes = Vec::new();
// Text layers showing other text or style than their main layer (to re-measure).
let mut remeasure = Vec::new();
for s in &src {
let Some(orig) = doc.node(*s) else { continue };
let Some(&nid) = map.get(s) else { continue };
let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
let mut n = orig.clone();
n.id = nid;
n.parent = Some(parent);
n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect();
n.source = Some(*s);
n.overrides.clear();
n.prop_refs.clear();
let prev = old.get(&nid);
// Annotations belong to the layer they were added to, not to its copies.
n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default();
n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default();
if let Some(o) = prev {
carry_overrides(o, &mut n, Carry::Values);
keep_laid_out(doc, orig, o, &mut n);
}
if !edited.contains(&nid) {
// No longer a slot in the main: it mirrors the main again.
n.overrides.remove(SLOT);
}
// Component properties: the main layer's bindings use this instance's values; layers of
// nested instances use the nested instance's values (one level per source hop).
let enc = enclosing.get(&parent).cloned().unwrap_or_default();
apply_enclosing_props(doc, &mut n, orig, &enc, &inst_props);
if let NodeKind::Instance { component: c, props } = &n.kind {
inst_props.insert(nid, props.clone());
let mut e = enc;
e.push(nid);
enclosing.insert(nid, e);
if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) {
nested.push(nid);
}
} else {
enclosing.insert(nid, enc);
}
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind)
&& a != b
{
remeasure.push(nid);
}
new_nodes.push(n);
}
for n in new_nodes {
let (id, parent) = (n.id, n.parent);
if insert_linked(doc, n).is_err()
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
{
p.children.retain(|c| *c != id);
}
}
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
}
// A resized instance: children follow their constraints (auto layout re-flows later).
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
remeasure = map.values().copied().collect();
}
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
remeasure_text(doc, remeasure);
rebuild_nested(doc, nested, &old, &used, depth);
}
/// Geometry overrides of children win over constraints.
fn follow_geometry_overrides(
doc: &mut Document,
src: &[NodeId],
map: &BTreeMap<NodeId, NodeId>,
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
remeasure: &mut Vec<NodeId>,
) {
for s in src {
let Some(nid) = map.get(s).copied() else { continue };
let Some(o) = old.get(&nid) else { continue };
// A frame that kept the size its own resizing gave it: its layers (copied at their main
// layers' places) follow their constraints from its main layer's size.
if o.overrides.contains("resizing")
&& !o.overrides.contains("size")
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
{
figcraft_layout::apply_constraints(doc, nid, mw, mh);
}
if !o.overrides.contains("size") && !o.overrides.contains("position") {
continue;
}
remeasure.push(nid);
let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
if o.overrides.contains("size") {
// Through set_size so vector networks scale with the box.
figcraft_layout::set_size(doc, nid, o.width, o.height);
if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
figcraft_layout::apply_constraints(doc, nid, ow, oh);
}
}
if let Some(n) = doc.node_mut(nid) {
carry_overrides(o, n, Carry::Geometry);
}
}
}
/// Nested instances showing another main than the main's copy (swapped): rebuild from it,
/// with the old layers below them this rebuild didn't reuse.
fn rebuild_nested(doc: &mut Document, nested: Vec<NodeId>, old: &BTreeMap<NodeId, std::sync::Arc<Node>>, used: &BTreeSet<NodeId>, depth: usize) {
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> =
old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
for n in nested {
let mut below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
.iter()
.filter(|(_, x)| {
let mut cur = x.parent;
for _ in 0..=MAX_DEPTH * 4 {
match cur {
Some(p) if p == n => return true,
Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
None => return false,
}
}
false
})
.map(|(i, x)| (*i, x.clone()))
.collect();
take_placing_overrides(doc, n, old, &mut below);
sync_instance_depth(doc, n, depth + 1, below);
}
}
fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap<NodeId, BTreeMap<String, Value>>) {
let mut chain: Vec<&Node> = vec![orig];
while chain.len() < enc.len() {
let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
chain.push(next);
}
for (j, level) in chain.iter().enumerate().rev() {
let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
for (field, prop) in &level.prop_refs {
let g = field_group(field);
if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
continue;
}
if let Some(val) = props.get(prop) {
apply_prop(doc, n, field, val);
}
}
}
}
/// Text whose characters or style differ from the main re-measures.
fn remeasure_text(doc: &mut Document, remeasure: Vec<NodeId>) {
for nid in remeasure {
if let Some(n) = doc.node(nid)
&& let Some((w, h)) = figcraft_text::auto_size(n)
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
{
figcraft_layout::set_size(doc, nid, w, h);
}
}
}
/// The component's property defaults with the instance's values for the ones it has.
fn effective_props(comp: &Node, root_props: &BTreeMap<String, Value>) -> BTreeMap<String, Value> {
let mut eff_props = default_props(comp);
for (k, v) in root_props {
if eff_props.contains_key(k) {
eff_props.insert(k.clone(), v.clone());
}
}
eff_props
}
type OldIndex = (BTreeMap<NodeId, std::sync::Arc<Node>>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap<NodeId, Option<NodeId>>);
/// The old layers of `inst` (and `spare`) by id, by (parent, source) and by source alone (`None`
/// when several layers mirror it).
fn index_old_layers(doc: &Document, inst: NodeId, spare: BTreeMap<NodeId, std::sync::Arc<Node>>) -> OldIndex {
let mut old_ids = old_layers(doc, inst);
old_ids.extend(spare.keys().copied());
old_ids.sort();
old_ids.dedup();
let mut old = spare;
let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
let mut by_src: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
for i in &old_ids {
let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
if let Some(s) = n.source {
if let Some(p) = n.parent {
by_source.entry((p, s)).or_insert(*i);
}
by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
}
old.insert(*i, n);
}
(old, by_source, by_src)
}
/// The rebuilt root of instance `inst` of `comp`.
fn fresh_root(doc: &Document, root: &mut Node, comp: &Node, inst: NodeId, component: NodeId, map: &BTreeMap<NodeId, NodeId>) -> Node {
// Root: follows the main except its own placement, name, size and overrides.
let order: Vec<NodeId> = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect();
let mut fresh = comp.clone();
fresh.id = inst;
fresh.parent = root.parent;
fresh.children = order;
fresh.transform = root.transform;
fresh.kind = root.kind.clone();
carry_prop_bindings(root, &mut fresh);
// A nested instance keeps mirroring its layer of the outer main.
let nested_copy = inside_instance(doc, inst);
fresh.source = if nested_copy { root.source } else { Some(component) };
fresh.constraints = root.constraints;
fresh.child_layout = root.child_layout.clone();
fresh.interactions = root.interactions.clone();
fresh.exports = root.exports.clone();
fresh.prop_refs = root.prop_refs.clone();
fresh.plugin_data = root.plugin_data.clone();
fresh.annotations = root.annotations.clone();
// Code links belong to the main component; instances read them from it.
fresh.code_links.clear();
// An instance's own dev resources (its main's are listed with them, not copied).
fresh.dev_resources = root.dev_resources.clone();
fresh.measurements = root.measurements.clone();
fresh.locked = root.locked;
fresh.visible = root.visible;
fresh.var_modes = root.var_modes.clone();
fresh.is_mask = root.is_mask;
fresh.exposed = root.exposed;
fresh.guides = root.guides.clone();
fresh.dev_status = root.dev_status;
fresh.dev_changed = root.dev_changed;
fresh.dev_note = root.dev_note.clone();
fresh.constrain_proportions = root.constrain_proportions;
// How the instance combines in a boolean group it sits in is its own.
fresh.bool_op = root.bool_op;
// A swapped nested instance keeps the name its layer was given in the outer main.
let renamed = nested_copy
&& root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind {
NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c),
_ => false,
});
fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) };
// Switched to another variant: its own overrides stay where both variants agreed.
if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
let differs = diff_groups(prev, comp);
root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout);
}
carry_overrides(root, &mut fresh, Carry::Root);
fresh
}
/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
/// copies just made of the outer main's, and `below`) takes the overrides held along the source
/// chain it had before this rebuild (`before`), through layers inside instances in the mains that
/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a
/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main.
/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value.
fn take_placing_overrides(
doc: &mut Document,
n: NodeId,
before: &BTreeMap<NodeId, std::sync::Arc<Node>>,
below: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
) {
let placing = placing_mains(doc, n);
for id in doc.subtree(n).into_iter().filter(|i| *i != n) {
let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default();
if let Some(layer) = doc.node_mut(id) {
take(layer, &held);
}
}
for layer in below.values_mut() {
let held = held_along(doc, layer, &placing);
if !held.is_empty() {
take(std::sync::Arc::make_mut(layer), &held);
}
}
}
/// The mains placing nested instance `n`: those of the layers it mirrors, outward.
fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet<NodeId> {
let mut out = BTreeSet::new();
let mut cur = doc.node(n).and_then(|x| x.source);
for _ in 0..=MAX_DEPTH {
let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break };
out.extend(owning_component(doc, s.id));
cur = s.source;
}
out
}
/// The overrides but geometry and slot content held along `layer`'s source chain while it runs
/// through layers inside instances in `placing` mains, each with the nearest layer holding it.
fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet<NodeId>) -> BTreeMap<String, std::sync::Arc<Node>> {
let mut out = BTreeMap::new();
let mut cur = layer.source;
for _ in 0..=MAX_DEPTH {
let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break };
if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) {
break;
}
for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) {
out.entry(g.clone()).or_insert_with(|| s.clone());
}
cur = s.source;
}
out
}
/// Give `layer` the `held` groups it has no override of its own for.
fn take(layer: &mut Node, held: &BTreeMap<String, std::sync::Arc<Node>>) {
for (g, from) in held {
if layer.overrides.insert(g.clone()) {
copy_group(from, layer, g);
}
}
}
/// Whether main components `a` and `b` are two variants of one set.
fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool {
a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s))
}
/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched
/// variants): `o` without the overrides of properties the two variants' layers differ in (`None`
/// when it keeps them all). Text layers keep every override.
fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option<Node> {
let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?;
if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) {
return None;
}
if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) {
return None;
}
let differs = diff_groups(from, doc.node(s)?);
let mut m = o.clone();
m.overrides.retain(|g| !differs.contains(&g.as_str()));
(m.overrides.len() != o.overrides.len()).then_some(m)
}
/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of
/// its slots): what swapping it from the Assets panel keeps.
pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) {
for d in old_layers(doc, inst).into_iter().chain([inst]) {
let Some(n) = doc.node(d) else { continue };
let text = matches!(n.kind, NodeKind::Text(_));
let keep = |g: &String| text || g == SLOT || (d == inst && g == "size");
if !n.overrides.iter().all(keep)
&& let Some(m) = doc.node_mut(d)
{
m.overrides.retain(keep);
}
}
}
/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old
/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually
/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever
/// something that sizes or places layers did change.
fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) {
if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") {
return;
}
let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
if flows {
// The main's rotation and skew, the old place.
let [a, b, c, d, _, _] = n.transform.as_coeffs();
let [.., e, f] = o.transform.as_coeffs();
n.transform = Affine::new([a, b, c, d, e, f]);
}
// Only layers whose shape is their box (a vector's points scale with it).
let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. });
let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows));
if derived(n.child_layout.sizing_h) {
n.width = o.width;
}
if derived(n.child_layout.sizing_v) {
n.height = o.height;
}
}
/// The layers of instance `inst` a rebuild matches against (every layer below it but the content
/// of slots holding its own), ascending.
fn old_layers(doc: &Document, inst: NodeId) -> Vec<NodeId> {
let mut out = Vec::new();
let mut stack = vec![inst];
while let Some(id) = stack.pop() {
let Some(n) = doc.node(id) else { continue };
for c in &n.children {
out.push(*c);
if out.len() > 1_000_000 {
break;
}
if doc.node(*c).is_some_and(|cn| !holds_content(cn)) {
stack.push(*c);
}
}
}
out.sort();
out.dedup();
out
}
/// Insert a node whose parent/children links are already set (used when rebuilding instances).
pub(super) fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> {
let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?;
let id = n.id;
let mut n = n;
let kids = std::mem::take(&mut n.children);
doc.insert(n, parent, None)?;
if let Some(m) = doc.node_mut(id) {
m.children = kids;
}
// The parent may already list us (its children were copied with ids): keep the first.
if let Some(p) = doc.node_mut(parent) {
let mut seen = BTreeSet::new();
p.children.retain(|c| seen.insert(*c));
}
Ok(())
}
@@ -383,6 +383,22 @@ fn resized_instances_follow_constraints_and_auto_layout() {
healthy(&s);
}
/// The tail of `component_properties_apply_to_instances`: unbind, delete, then a variant property.
fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId, dot: NodeId) {
// Unbind and delete.
ex(s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
assert!(node(s, copy_of(s, i, dot)).visible);
ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
assert!(node(s, title).prop_refs.is_empty());
assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
let desc = ex(s, "component.properties", json!({"id": i}));
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
// A variant property on a lone component makes it a component set.
let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. }));
assert_eq!(node(s, c).name, "Theme=Light");
}
#[test]
fn component_properties_apply_to_instances() {
let mut s = Session::new();
@@ -414,18 +430,7 @@ fn component_properties_apply_to_instances() {
assert!(s.execute("component.bindProperty", &json!({"layer": dot, "property": "Heading"})).is_err());
assert!(s.execute("component.bindProperty", &json!({"layer": ititle, "property": "Heading"})).is_err());
assert!(s.execute("component.addProperty", &json!({"id": c, "name": "Heading", "type": "text"})).is_err());
// Unbind and delete.
ex(&mut s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
assert!(node(&s, copy_of(&s, i, dot)).visible);
ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
assert!(node(&s, title).prop_refs.is_empty());
assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
let desc = ex(&mut s, "component.properties", json!({"id": i}));
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
// A variant property on a lone component makes it a component set.
let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
assert!(matches!(node(&s, set).kind, NodeKind::ComponentSet { .. }));
assert_eq!(node(&s, c).name, "Theme=Light");
unbind_delete_and_vary(&mut s, c, i, title, dot);
healthy(&s);
}
@@ -0,0 +1,147 @@
use super::{BTreeMap, Document, MAX_DEPTH, Node, NodeId, NodeKind, holds_content, inside_instance};
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
/// the left-most).
pub fn default_variant(doc: &Document, set: NodeId) -> Option<NodeId> {
let corner = |c: &NodeId| {
doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x))
};
let kids = &doc.node(set)?.children;
kids.iter()
.copied()
.filter_map(|c| corner(&c).map(|k| (k, c)))
.min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, c)| c)
}
/// Variants of a set sharing one combination of property values (each group two or more, in
/// layer order): a conflict to resolve by giving each a unique combination.
pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec<Vec<NodeId>> {
let mut by: BTreeMap<BTreeMap<String, String>, Vec<NodeId>> = BTreeMap::new();
for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) {
by.entry(variant_props(&n.name)).or_default().push(c);
}
}
by.into_values().filter(|v| v.len() > 1).collect()
}
/// Parse a variant name "Size=Large, State=Hover" into pairs.
pub fn variant_props(name: &str) -> BTreeMap<String, String> {
name.split(',')
.filter_map(|kv| {
let (k, v) = kv.split_once('=')?;
Some((k.trim().to_string(), v.trim().to_string()))
})
.collect()
}
/// Format variant pairs as a layer name, in the given property order (others after, sorted).
pub fn variant_name(pairs: &BTreeMap<String, String>, order: &[String]) -> String {
let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect();
keys.extend(pairs.keys().filter(|k| !order.contains(k)));
keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::<Vec<_>>().join(", ")
}
/// Variant properties of a set: property → values, in first-seen order.
pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec<String>)> {
let mut out: Vec<(String, Vec<String>)> = Vec::new();
for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() {
let Some(cn) = doc.node(c) else { continue };
for kv in cn.name.split(',') {
let Some((k, v)) = kv.split_once('=') else { continue };
let (k, v) = (k.trim().to_string(), v.trim().to_string());
match out.iter_mut().find(|(p, _)| *p == k) {
Some((_, vals)) => {
if !vals.contains(&v) {
vals.push(v);
}
}
None => out.push((k, vec![v])),
}
}
}
out
}
/// The variant in `set` matching `wanted` best: all given properties equal, or else the most.
pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap<String, String>) -> Option<NodeId> {
let s = doc.node(set)?;
let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count());
if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) {
return Some(exact);
}
s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0))
}
/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each
/// layer's `source` is the main layer at the same place, swapped nested instances mirror their
/// own main, slots holding the instance's own content aren't compared. Returns the problems found.
pub fn mirror_issues(doc: &Document) -> Vec<String> {
doc.nodes()
.filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id))
.flat_map(|n| instance_issues(doc, n.id))
.collect()
}
/// [`mirror_issues`] for one instance.
pub(super) fn instance_issues(doc: &Document, id: NodeId) -> Vec<String> {
fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec<String>) {
if depth > MAX_DEPTH || holds_content(inst) {
return;
}
if inst.children.len() != main.children.len() {
out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len()));
return;
}
for (a, b) in inst.children.iter().zip(&main.children) {
let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue };
if an.source != Some(bn.id) {
out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id));
continue;
}
match (&an.kind, &bn.kind) {
(NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => {
if let Some(m) = doc.node(*x) {
walk(doc, an, m, depth + 1, out);
}
}
_ => walk(doc, an, bn, depth + 1, out),
}
}
}
let mut out = Vec::new();
if let Some(n) = doc.node(id)
&& let NodeKind::Instance { component, .. } = &n.kind
&& let Some(m) = doc.node(*component)
{
walk(doc, n, m, 0, &mut out);
}
out
}
/// The nested instance copies inside instance `id` whose main-component counterpart is exposed
/// (top-most first; an exposed instance's own exposed nested instances follow it).
pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec<NodeId> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
let mut seen = 0usize;
while let Some(c) = stack.pop() {
seen += 1;
if seen > 100_000 {
break;
}
let Some(n) = doc.node(c) else { continue };
// A hidden exposed instance hides its properties too.
let exposed =
n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed);
if exposed {
out.push(c);
}
// Only exposed nested instances pass their own exposed instances up.
if !matches!(n.kind, NodeKind::Instance { .. }) || exposed {
stack.extend(n.children.iter().rev().copied());
}
}
out
}
@@ -15,7 +15,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use figcraft_doc::proto::Action;
use figcraft_doc::*;
use figcraft_doc::{
Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink,
LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId,
VariableValue, Variables, node_images, paint_images, used_images,
};
use serde_json::Value;
use crate::components::inside_instance;
@@ -439,9 +443,9 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,
Ok(PublishedLibrary { manifest, doc: Arc::new(snap) })
}
/// When a library file was last read.
/// When a file was last read: its length and modification time (also a document's own file, #1330).
#[derive(Clone, Debug, PartialEq)]
struct Stamp {
pub(crate) struct Stamp {
len: u64,
modified: Option<std::time::SystemTime>,
}
@@ -545,7 +549,7 @@ fn now() -> Option<std::time::SystemTime> {
}
#[cfg(not(target_arch = "wasm32"))]
fn stat(path: &str) -> std::result::Result<Stamp, String> {
pub(crate) fn stat(path: &str) -> std::result::Result<Stamp, String> {
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
if !m.is_file() {
return Err(format!("{path} is not a file"));
@@ -554,7 +558,7 @@ fn stat(path: &str) -> std::result::Result<Stamp, String> {
}
#[cfg(target_arch = "wasm32")]
fn stat(_path: &str) -> std::result::Result<Stamp, String> {
pub(crate) fn stat(_path: &str) -> std::result::Result<Stamp, String> {
Err(NO_FILES.into())
}
@@ -1331,5 +1335,4 @@ pub fn published_variables(lib: &PublishedLibrary) -> impl Iterator<Item = &Vari
}
#[cfg(test)]
#[path = "library_tests.rs"]
mod tests;
@@ -107,6 +107,49 @@ fn publish_to(s: &mut Session, path: &str) -> String {
r["id"].as_str().unwrap().to_string()
}
/// `publish_manifest_roundtrip`: the file saved and read back keeps the manifest.
fn saved_and_read_back(doc: &Document, lib: &PublishedLibrary) {
let m = &lib.manifest;
// Saved and read back: the author's file and a reader of the library see the same manifest.
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
assert!(issues.is_empty(), "{issues:?}");
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
assert_eq!(read.manifest, *m);
assert_eq!(read.doc.node_count(), lib.doc.node_count());
// Older files and files never published have no library; readers that don't know libraries
// are unaffected (the entries are extra archive members).
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
}
/// `publish_manifest_roundtrip`: a republish bumps only what changed.
fn republish_bumps_touched(s: &mut Session, k: &Kit) {
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
let e = s.execute("library.publish", &json!({})).unwrap_err();
assert!(e.to_string().contains("no changes"), "{e}");
ex(s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
let r = ex(s, "library.publish", json!({}));
assert_eq!(r["version"], 2);
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
assert_eq!(m.description, "Our kit", "the description carries over");
// Excluding an asset; a used component still comes along (not offered).
let r = ex(s, "library.publish", json!({"exclude": ["Button"]}));
assert_eq!(r["components"], 1);
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
assert!(m.component(k.button).is_some_and(|a| !a.published));
// Unknown assets are an error, not ignored.
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
// Undo takes the publish back.
s.undo().unwrap();
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
}
#[test]
fn publish_manifest_roundtrip() {
let mut s = Session::new();
@@ -126,41 +169,74 @@ fn publish_manifest_roundtrip() {
assert!(lib.doc.node(k.label).is_some());
assert!(lib.doc.validate().is_empty());
assert_eq!(lib.doc.styles.len(), 1);
// Saved and read back: the author's file and a reader of the library see the same manifest.
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
assert!(issues.is_empty(), "{issues:?}");
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
assert_eq!(read.manifest, *m);
assert_eq!(read.doc.node_count(), lib.doc.node_count());
// Older files and files never published have no library; readers that don't know libraries
// are unaffected (the entries are extra archive members).
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
saved_and_read_back(doc, &lib);
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
let e = s.execute("library.publish", &json!({})).unwrap_err();
assert!(e.to_string().contains("no changes"), "{e}");
ex(&mut s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
let r = ex(&mut s, "library.publish", json!({}));
assert_eq!(r["version"], 2);
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
assert_eq!(m.description, "Our kit", "the description carries over");
// Excluding an asset; a used component still comes along (not offered).
let r = ex(&mut s, "library.publish", json!({"exclude": ["Button"]}));
assert_eq!(r["components"], 1);
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
assert!(m.component(k.button).is_some_and(|a| !a.published));
// Unknown assets are an error, not ignored.
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
// Undo takes the publish back.
s.undo().unwrap();
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
republish_bumps_touched(&mut s, &k);
}
/// `add_place_and_update_keep_overrides`: library copies refuse every edit and roll back.
fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
let unchanged = s.doc().unwrap().clone();
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
assert!(e.to_string().contains("can't be edited"), "{e}");
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
for (cmd, args) in
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
{
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
}
let style = s.doc().unwrap().styles[0].id.clone();
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
assert!(e.to_string().contains("library"), "{e}");
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
assert_eq!(fill(s, remote_button), "0000FF");
}
/// `add_place_and_update_keep_overrides`: the library changes and publishes; the copies follow. Returns the Card's instance.
fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str, [b, b_label, remote_button, main]: [NodeId; 4]) -> NodeId {
// The library changes the Button (fill via its style, and its label) and publishes again.
ex(lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
ex(lib, "text.set", json!({"id": k.label, "characters": "Go"}));
ex(lib, "library.publish", json!({}));
ex(lib, "file.save", json!({}));
let u = ex(s, "library.updates", json!({}));
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
ex(s, "library.acceptUpdates", json!({}));
assert_eq!(fill(s, b), "00FF00", "the instance follows the updated main");
assert_eq!(chars(s, b_label), "Buy", "its override is kept");
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
assert_eq!(fill(s, remote_button), "00FF00");
let style = s.doc().unwrap().styles[0].clone();
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
let doc = s.doc().unwrap();
assert_eq!(doc.libraries.link(lid).map(|l| l.version), Some(2));
// The Card's nested Button follows too.
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
let nested = node(s, card_inst).children[0];
assert_eq!(fill(s, nested), "00FF00");
assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
healthy(s);
// A structural change: the Button gains a layer (the Card's nested Button too).
ex(lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
ex(lib, "library.publish", json!({}));
ex(lib, "file.save", json!({}));
ex(s, "library.acceptUpdates", json!({"ids": [lid]}));
assert_eq!(node(s, b).children.len(), 2);
assert_eq!(node(s, nested).children.len(), 2);
assert_eq!(chars(s, b_label), "Buy", "overrides survive a structural update");
assert_eq!(s.doc().unwrap().libraries.link(lid).map(|l| l.version), Some(3));
healthy(s);
// Overrides still work after the update; resetting shows the library's text.
ex(s, "component.resetInstance", json!({"ids": [b]}));
assert_eq!(chars(s, node(s, b).children[0]), "Go");
card_inst
}
#[test]
@@ -211,63 +287,9 @@ fn add_place_and_update_keep_overrides() {
NodeKind::Instance { component, .. } => component,
_ => panic!("not an instance"),
};
let unchanged = s.doc().unwrap().clone();
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
assert!(e.to_string().contains("can't be edited"), "{e}");
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
for (cmd, args) in
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
{
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
}
let style = s.doc().unwrap().styles[0].id.clone();
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
assert!(e.to_string().contains("library"), "{e}");
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
assert_eq!(fill(&s, remote_button), "0000FF");
assert_remote_read_only(&mut s, b, remote_button);
// The library changes the Button (fill via its style, and its label) and publishes again.
ex(&mut lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
ex(&mut lib, "text.set", json!({"id": k.label, "characters": "Go"}));
ex(&mut lib, "library.publish", json!({}));
ex(&mut lib, "file.save", json!({}));
let u = ex(&mut s, "library.updates", json!({}));
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!(fill(&s, b), "00FF00", "the instance follows the updated main");
assert_eq!(chars(&s, b_label), "Buy", "its override is kept");
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
assert_eq!(fill(&s, remote_button), "00FF00");
let style = s.doc().unwrap().styles[0].clone();
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
let doc = s.doc().unwrap();
assert_eq!(doc.libraries.link(&lid).map(|l| l.version), Some(2));
// The Card's nested Button follows too.
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
let nested = node(&s, card_inst).children[0];
assert_eq!(fill(&s, nested), "00FF00");
assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
healthy(&s);
// A structural change: the Button gains a layer (the Card's nested Button too).
ex(&mut lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
ex(&mut lib, "library.publish", json!({}));
ex(&mut lib, "file.save", json!({}));
ex(&mut s, "library.acceptUpdates", json!({"ids": [lid]}));
assert_eq!(node(&s, b).children.len(), 2);
assert_eq!(node(&s, nested).children.len(), 2);
assert_eq!(chars(&s, b_label), "Buy", "overrides survive a structural update");
assert_eq!(s.doc().unwrap().libraries.link(&lid).map(|l| l.version), Some(3));
healthy(&s);
// Overrides still work after the update; resetting shows the library's text.
ex(&mut s, "component.resetInstance", json!({"ids": [b]}));
assert_eq!(chars(&s, node(&s, b).children[0]), "Go");
let card_inst = library_updates_flow(&mut s, &mut lib, &k, &lid, [b, b_label, remote_button, main]);
// Saved and reopened, the document keeps its links, copies and records.
let user_path = path_str(&d.join("app.figcraft"));
@@ -212,5 +212,4 @@ impl Session {
}
#[cfg(test)]
#[path = "library_server_tests.rs"]
mod tests;
@@ -6,7 +6,7 @@
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryManifest, NodeId, NodeKind, PublishedLibrary, VariableValue};
use serde_json::Value;
use crate::cmd::component::{main_of, swap_instance};
@@ -41,6 +41,35 @@ fn b_main(doc: &Document, a_main: NodeId, a_root: NodeId, b_root: NodeId) -> Opt
}
}
/// Local style id → the library's style id, matched by name and type (the rest go to `out.unmatched`).
fn match_styles(doc: &Document, recs: &[ImportedRecord], m: &LibraryManifest, out: &mut Swapped) -> BTreeMap<String, String> {
let mut keys = BTreeMap::new();
for r in recs {
let Some(st) = doc.style(&r.local) else { continue };
let kind = st.kind.type_name();
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
Some(x) => {
keys.insert(r.local.clone(), x.id.clone());
}
None => out.unmatched.push(format!("style {}", st.name)),
}
}
keys
}
/// (local component root, the library's component id) by name; the rest go to `out.unmatched`.
fn match_components(doc: &Document, records: &[ImportedComponent], m: &LibraryManifest, out: &mut Swapped) -> Vec<(NodeId, NodeId)> {
let mut pairs = Vec::new();
for r in records {
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
match m.components.iter().find(|a| a.published && a.name == name) {
Some(a) => pairs.push((r.root, a.id)),
None => out.unmatched.push(format!("component {name}")),
}
}
pairs
}
/// Swap the copies of library `from` in `doc` to library `to` by names (see the module docs).
pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Result<Swapped> {
let m = &to.manifest;
@@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
})
.collect();
let records: Vec<ImportedComponent> = doc.libraries.components.iter().filter(|r| r.library == from && shown.contains(&r.root)).cloned().collect();
let mut pairs = Vec::new();
for r in &records {
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
match m.components.iter().find(|a| a.published && a.name == name) {
Some(a) => pairs.push((r.root, a.id)),
None => out.unmatched.push(format!("component {name}")),
}
}
let pairs = match_components(doc, &records, m, &mut out);
let keys: Vec<NodeId> = pairs.iter().map(|(_, k)| *k).collect();
let map = if keys.is_empty() { BTreeMap::new() } else { import(doc, to, &keys, false)? };
// A's mains (each variant) and roots → B's.
@@ -121,17 +143,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
// Styles the document's own layers link to: by name and type.
let linked: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect();
let style_recs: Vec<ImportedRecord> = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect();
let mut style_keys: BTreeMap<String, String> = BTreeMap::new();
for r in &style_recs {
let Some(st) = doc.style(&r.local) else { continue };
let kind = st.kind.type_name();
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
Some(x) => {
style_keys.insert(r.local.clone(), x.id.clone());
}
None => out.unmatched.push(format!("style {}", st.name)),
}
}
let style_keys = match_styles(doc, &style_recs, m, &mut out);
let imported = import_styles(doc, to, &style_keys.values().cloned().collect(), false);
let styles: BTreeMap<String, String> = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect();
out.styles = styles.len();
@@ -245,5 +257,4 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
}
#[cfg(test)]
#[path = "library_swap_tests.rs"]
mod tests;
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::CodeLang;
use crate::params::*;
use crate::params::{get, id_of, num, string, targets};
use crate::{EngineError, Result, Session, bad, spec};
/// The component a playground for `target` shows: an instance's component, a component or a
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"component.playground",
"Component playground",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose|arkui"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
@@ -8,7 +8,10 @@ use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use figcraft_color::PaintKind;
use figcraft_doc::*;
use figcraft_doc::{
CollectionId, Document, EffectKind, LetterSpacing, LineHeight, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextStyle, VariableId,
VariableType, VariableValue, Variables,
};
use crate::cmd::lists::{BoundList, list_binding, stop_binding};
+5
View File
@@ -0,0 +1,5 @@
pub mod batch;
pub mod doc_urls;
pub mod file_access;
pub mod inspect;
pub mod params;

Some files were not shown because too many files have changed in this diff Show More