diff --git a/.cargo/config.toml b/.cargo/config.toml index acebfe9f0..30d819a23 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,5 +3,13 @@ [env] CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } +# The Windows UI Automation adapter can be asked about a node that disappeared in the +# same frame. On MinGW, converting that HRESULT through windows-result's thread COM +# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra +# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU +# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks. +[target.'cfg(all(windows, target_env = "gnu"))'] +rustflags = ["--cfg=windows_slim_errors"] + [alias] xtask = "run -q -p xtask --" diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1968076f2..b79af5028 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,10 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: - # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter): - # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts' - # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin - # once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 + require-craft-fonts: true packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 3b03a99c3..80adf16a5 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 386989022..da85536c4 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd2aefddb..f93219fae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,12 @@ jobs: with: app-name: figcraft app-title: FigCraft - # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only - # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. - # Re-pin once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 has-server: true # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). - # crates/text/build.rs keeps the web build to Inter. + # crates/text/build.rs keeps the web allowlist small (Inter and Georgian). web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4cceb9225..267479eb8 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -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 diff --git a/.gitignore b/.gitignore index 928d6b498..a22202885 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ plans/ /log/ .DS_Store *.profraw +.cache/ +.vscode/ diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 96dff6a9e..cfcdbf067 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,8 +26,23 @@ Release builds embed fonts from the optional craft-fonts build input |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | | Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | -| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | +| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | -| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | + +The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4. +FigCraft filters both families from every build in accordance with its asset policy. egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates. + +## egui dependency font icons + +| Asset | Author | Source | Licence | Use | +|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | + +## 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 | diff --git a/CLAUDE.md b/CLAUDE.md index b2384311a..f61412ba7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/Cargo.lock b/Cargo.lock index fabd85f82..335b0d625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -700,9 +695,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -753,9 +748,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -836,6 +831,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -849,8 +854,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", + "core-foundation 0.9.4", + "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", ] @@ -862,10 +880,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "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" @@ -899,17 +939,158 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-atomic" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "rustix 1.1.5", +] + [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-font-catalog" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fontique", + "skrifa 0.47.0", + "thiserror 2.0.21", + "windows-registry", +] + +[[package]] +name = "craft-font-store" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-atomic", + "craft-font-catalog", + "flate2", + "serde", + "serde_json", + "sha2", + "skrifa 0.47.0", + "thiserror 2.0.21", + "ureq", +] + +[[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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 9.1.1", +] [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ - "egui", "wayland-client", ] @@ -922,6 +1103,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" @@ -1032,6 +1222,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_arbitrary" version = "1.5.0" @@ -1043,6 +1239,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" @@ -1124,10 +1326,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1135,9 +1342,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1159,6 +1365,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1170,9 +1377,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1190,9 +1396,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1210,30 +1415,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1245,9 +1453,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1265,9 +1472,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1292,22 +1498,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1356,9 +1562,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1374,16 +1579,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1465,9 +1671,11 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.10.0" +version = "0.12.1" dependencies = [ "arboard", + "craft-menu", + "craft-update", "craft-window", "eframe", "egui", @@ -1480,6 +1688,8 @@ dependencies = [ "fmv-macos-events", "image", "log", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "rfd", "serde_json", "wat", @@ -1487,11 +1697,12 @@ dependencies = [ "windows", "winit", "winresource", + "zbus", ] [[package]] name = "figcraft-cli" -version = "0.10.0" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1505,7 +1716,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.10.0" +version = "0.12.1" dependencies = [ "base64 0.22.1", "chrono", @@ -1516,12 +1727,13 @@ dependencies = [ "postcard", "serde", "serde_json", + "sha2", "thiserror 2.0.21", ] [[package]] name = "figcraft-collab-net" -version = "0.10.0" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1544,7 +1756,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.10.0" +version = "0.12.1" dependencies = [ "kurbo", "serde", @@ -1553,7 +1765,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.10.0" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1565,10 +1777,13 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.10.0" +version = "0.12.1" dependencies = [ "base64 0.22.1", "craft-docpath", + "craft-font-catalog", + "craft-font-store", + "deunicode", "figcraft-collab", "figcraft-color", "figcraft-doc", @@ -1581,34 +1796,39 @@ dependencies = [ "figcraft-render", "figcraft-svg", "figcraft-text", + "figcraft-vectordrawable", "image", "log", "regex", + "same-file", "serde", "serde_json", "thiserror 2.0.21", "wat", "web-time", + "zip 2.6.1", ] [[package]] name = "figcraft-format" -version = "0.10.0" +version = "0.12.1" dependencies = [ "figcraft-doc", + "figcraft-layout", "figcraft-pathops", "flate2", "image", "ruzstd", "serde", "serde_json", + "sha1", "sha2", - "zip", + "zip 2.6.1", ] [[package]] name = "figcraft-geom" -version = "0.10.0" +version = "0.12.1" dependencies = [ "kurbo", "serde", @@ -1616,7 +1836,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.10.0" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1624,7 +1844,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.10.0" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1640,7 +1860,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.10.0" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1649,7 +1869,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.10.0" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1666,7 +1886,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.10.0" +version = "0.12.1" dependencies = [ "figcraft-color", "serde", @@ -1679,11 +1899,12 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.10.0" +version = "0.12.1" dependencies = [ "crc32fast", "figcraft-color", "figcraft-doc", + "figcraft-format", "figcraft-geom", "figcraft-pathops", "figcraft-text", @@ -1697,7 +1918,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.10.0" +version = "0.12.1" dependencies = [ "crc32fast", "ctrlc", @@ -1716,12 +1937,12 @@ dependencies = [ "toml", "tungstenite", "wat", - "zip", + "zip 2.6.1", ] [[package]] name = "figcraft-svg" -version = "0.10.0" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1737,12 +1958,15 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.10.0" +version = "0.12.1" dependencies = [ + "core-text", + "craft-font-catalog", "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", "log", + "objc2 0.6.5", "png", "skrifa 0.47.0", "unicode-bidi", @@ -1751,10 +1975,12 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.10.0" +version = "0.12.1" dependencies = [ "base64 0.22.1", "chrono", + "craft-ui", + "craft-update", "egui", "egui_extras", "figcraft-color", @@ -1776,9 +2002,24 @@ dependencies = [ ] [[package]] -name = "figcraft-web" -version = "0.10.0" +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", @@ -1786,17 +2027,39 @@ dependencies = [ "figcraft-ui-egui", "js-sys", "log", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" 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" @@ -1892,6 +2155,28 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.41.0", + "roxmltree 0.21.2", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2033,9 +2318,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2061,9 +2348,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -2293,6 +2580,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" @@ -2301,11 +2605,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" @@ -2317,6 +2642,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2326,6 +2654,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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.3.0" @@ -2355,6 +2709,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2362,6 +2719,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" @@ -2593,6 +2974,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" @@ -2613,7 +3004,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2726,6 +3117,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" @@ -2756,9 +3150,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -2802,6 +3196,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" @@ -2897,6 +3309,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -3068,6 +3486,16 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3252,6 +3680,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3264,9 +3698,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -3320,6 +3754,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + [[package]] name = "pdf-writer" version = "0.15.0" @@ -3364,9 +3804,9 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -3375,9 +3815,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -3385,9 +3825,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -3398,9 +3838,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -3460,6 +3900,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -3541,9 +3994,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3621,6 +4082,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -3881,9 +4351,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" dependencies = [ "memchr", ] @@ -3950,6 +4420,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3959,6 +4441,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.16" @@ -4036,6 +4545,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4059,6 +4577,29 @@ dependencies = [ "tiny-skia 0.11.4", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4070,6 +4611,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -4256,9 +4807,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -4442,6 +4993,28 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4518,6 +5091,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4571,11 +5174,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4587,9 +5191,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.54.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "pin-project-lite", ] @@ -4722,6 +5326,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -4811,6 +5421,36 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4838,7 +5478,7 @@ dependencies = [ "kurbo", "log", "pico-args", - "roxmltree 0.21.1", + "roxmltree 0.21.2", "simplecss", "siphasher", "skrifa 0.44.0", @@ -4851,6 +5491,18 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4865,9 +5517,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "serde_core", @@ -4876,9 +5528,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -4891,9 +5543,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -4901,6 +5553,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5209,7 +5871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -5247,9 +5909,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", @@ -5262,10 +5924,19 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.10" +name = "webpki-root-certs" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -5693,8 +6364,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5705,8 +6375,8 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", - "core-graphics", + "core-foundation 0.9.4", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", @@ -5751,6 +6421,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.34" @@ -5761,6 +6441,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -5798,11 +6484,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" @@ -5881,17 +6576,28 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.10.0" +version = "0.12.1" dependencies = [ "figcraft-engine", "serde_json", ] [[package]] -name = "yoke" -version = "0.8.3" +name = "yeslogic-fontconfig-sys" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6070,16 +6776,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -6087,9 +6797,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", @@ -6111,6 +6821,23 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6190,3 +6917,23 @@ dependencies = [ "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_kittest" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 658093159..9177f2089 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +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" } @@ -32,11 +33,24 @@ figcraft-mcp = { path = "crates/mcp" } figcraft-collab-net = { path = "crates/collab-net" } figcraft-plugin = { path = "crates/plugin" } +# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. # Which paths stored in a document may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +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). The `egui` feature adds `decorate`/`decorations`. -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } +# 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto +# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs). +craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +# 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"] } @@ -45,6 +59,7 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } regex = "1.13" log = "0.4" +same-file = "1.0.6" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" @@ -63,11 +78,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" @@ -79,9 +98,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable", wat = "1.261" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } +egui = "0.37" +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } rfd = "0.17" arboard = { version = "3.6", default-features = false } webbrowser = "1" @@ -90,6 +109,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" @@ -122,14 +144,32 @@ codegen-units = 4 # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" -opt-level = "s" +# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate. +opt-level = "z" lto = "fat" codegen-units = 1 debug = false strip = true panic = "abort" +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } + # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -[patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/NOTICE b/NOTICE index a0baaa279..9970631da 100644 --- a/NOTICE +++ b/NOTICE @@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index ebbf7fa03..75328de15 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,16 @@

Live sessions: three apps on one figcraft-server file, with cursors, selections, cursor chat and spotlight

+ + + A board file named Sprint retro on a dot grid: a Sprint 14 retro made from the retrospective template, with Went well, To improve and Next steps sections of sticky notes, vote counts of 3, 2, 1 and 1 on four stickies after a vote has ended, and below it a pull request flow chart from the flow chart template whose shapes are joined by straight and elbow connectors with Yes and No labels; a timer pill in the top right shows 4:30 left with pause, add a minute, stop and Results buttons +

Boards: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions

+ + + Dev Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu +

Dev Mode measurements: redlines placed between layers or along a side, saved in the file and following the layers · examples/ridgeline-app.figcraft

+ + ## Made in FigCraft @@ -198,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil

Prototype and hand off

- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), 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 count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. @@ -234,8 +244,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. diff --git a/ROADMAP.md b/ROADMAP.md index 3f709e548..20e611167 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; 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-29 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); 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 @@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~155–280 h** one agent | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | | Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | @@ -63,9 +63,9 @@ 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 | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | +| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | ## Features @@ -89,11 +89,11 @@ native speakers' reviews of the nine language drafts need people. | Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | -| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | +| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | -| Boards (whiteboarding; not in the weighted numbers) | ~35% | ~20% | 30–60 | [boards-parity](docs/boards-parity.md) | +| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) | ## Languages @@ -103,18 +103,18 @@ 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) | | Japanese | ja | 99% | draft (needs native review) | | German | de | 96% | draft (needs native review) | | Korean | ko | 95% | draft (needs native review) | -| Vietnamese | vi | 0% | none | +| Vietnamese | vi | 100% | draft (needs native review) | 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 and Indonesian 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,13 +127,34 @@ 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-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. +- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. 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** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. +- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. +- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. +- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). +- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. +- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). +- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged. +- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run + unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes + into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget + can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected + (`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets + 65%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). +- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. - **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged. @@ -143,9 +164,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged. - **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged. - **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged. +- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed + filenames open normally; the global `--no-system-fonts` flag also respects the terminator. + Binary regression coverage opens five such native files and checks their JSON info. + - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. +- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged. - **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. @@ -329,9 +355,51 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press more than a few pixels from the edited shape used to leave edit mode at once, so a drag over another layer moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | +| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | +| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | +| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | +| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged | +| 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged | +| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | +| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | +| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | +| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. | +| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged | +| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | +| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). | +| 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | +| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | +| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | +| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | +| 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | +| 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged | +| 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. | +| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged | +| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | +| 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged | +| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged | +| 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-27 | minor | M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. | +| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. 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 | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. | +| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | +| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | +| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | +| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | +| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | +| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | +| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | +| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | +| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | +| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | +| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | | 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged | @@ -394,6 +462,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | +| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged | +| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 3fe17e802..740e885be 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -20,6 +20,10 @@ impl Args { let mut bad: Option = None; let mut it = argv.iter(); while let Some(arg) = it.next() { + if arg == "--" { + a.positional.extend(it.cloned()); + break; + } let name = if let Some(n) = arg.strip_prefix("--") { n } else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) { @@ -87,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 { + 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 { match self.value("scale") { None => Ok(1.0), diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3d36a7f28..7b8226469 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -4,14 +4,20 @@ use std::ffi::OsStr; use std::path::Path; use std::process::ExitCode; -use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings}; -use figcraft_engine::Session; +use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat}; +use figcraft_engine::{BatchScope, BatchStep, Session}; use serde_json::{Value, json}; use crate::{CliError, CliResult}; pub fn load(path: &str) -> CliResult { - load_noted(path).map(|(d, _)| d) + let (doc, notes) = load_noted(path)?; + if ext(path) == "penpot" { + for note in notes { + eprintln!("note: {note}"); + } + } + Ok(doc) } /// The document with the loader's notes (repairs of a damaged file, or import warnings). @@ -25,7 +31,7 @@ fn ext(path: &str) -> String { fn image_format(e: &str) -> Option { match e { - "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e), + "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e), _ => None, } } @@ -120,7 +126,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> { "png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?, "pdf" => page_pdf(doc, page, &PrintSettings::default())?, "fig" => fig_bytes(doc)?, - other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))), + "penpot" => penpot_bytes(doc)?, + other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig, penpot)"))), }; write(out, &bytes) } @@ -134,6 +141,14 @@ fn fig_bytes(doc: &Document) -> CliResult> { Ok(bytes) } +fn penpot_bytes(doc: &Document) -> CliResult> { + let (bytes, warnings) = figcraft_engine::cmd::file::penpot_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?; + for w in warnings { + eprintln!("note: {w}"); + } + Ok(bytes) +} + /// A page as PDF: its top-level frames one page each, or (without frames) everything on one page. fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult> { let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) { @@ -225,6 +240,8 @@ pub struct Target { pub node: Option, /// PDF only. pub print: PrintSettings, + /// Android Vector Drawable (XML) only. + pub vd_compat: VdCompat, } pub fn export(file: &str, out: &str, t: &Target) -> CliResult { @@ -235,19 +252,28 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { println!("{out}"); return Ok(ExitCode::SUCCESS); } + if t.format.as_deref().map_or(ext(out) == "penpot", |f| f.eq_ignore_ascii_case("penpot")) { + write(out, &penpot_bytes(&doc)?)?; + println!("{out}"); + return Ok(ExitCode::SUCCESS); + } let page = page_named(&doc, t.page.as_deref())?; let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print); let fmt = match t.format.as_deref() { - Some(f) => { - image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))? - } + Some(f) => image_format(&f.to_ascii_lowercase()) + .ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig|penpot)")))?, 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 { @@ -292,8 +318,34 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { Ok(ExitCode::SUCCESS) } +fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> { + if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) { + return Err(CliError::Failed(format!( + "{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them." + ))); + } + Ok(()) +} + +fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> { + if let Some((input, images_left_out)) = input { + refuse_dropping_images(input, out, images_left_out)?; + } + if let Some(images_left_out) = active.images_lost_saving_to(out) { + refuse_dropping_images(out, out, images_left_out)?; + } + Ok(()) +} + pub fn convert(input: &str, out: &str) -> CliResult { - let doc = load(input)?; + let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?; + refuse_dropping_images(input, out, loaded.images_past_budget)?; + if ext(input) == "penpot" { + for note in &loaded.issues { + eprintln!("note: {note}"); + } + } + let doc = loaded.doc; let page = doc .current_page .filter(|p| doc.pages.contains(p)) @@ -318,7 +370,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul } /// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines. -pub fn parse_script(text: &str) -> Result, String> { +pub fn parse_script(text: &str) -> Result, String> { let steps: Vec = match serde_json::from_str::(text) { Ok(Value::Array(a)) => a, Ok(Value::Object(o)) if o.contains_key("commands") => { @@ -338,12 +390,14 @@ pub fn parse_script(text: &str) -> Result, String> { .enumerate() .map(|(i, s)| { let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?; + figcraft_engine::batch::refuse_unknown_step_keys(i, &s).map_err(|e| e.to_string())?; let params = match s.get("params") { None | Some(Value::Null) => json!({}), Some(p @ Value::Object(_)) => p.clone(), Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")), }; - Ok((cmd.to_string(), params)) + let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?; + Ok(BatchStep { name, ..BatchStep::new(cmd, params) }) }) .collect() } @@ -352,43 +406,44 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?; let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?; let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; - let mut s = match input { + let (mut s, input_images_left_out) = match input { Some(path) => { let mut s = Session::empty(); s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?; - s + let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0); + (s, left_out) } - None => Session::new(), + None => (Session::new(), 0), }; // One undo step and all-or-nothing, the way the same commands behave through `command.batch` - // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and - // a step that fails takes the whole script back instead of leaving the earlier steps applied. - // The result lines are held back until the script is through, so none of them reports work that - // was rolled back. - let mut results: Vec = Vec::with_capacity(steps.len()); + // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the + // steps before it, and a step that fails takes the whole script back instead of leaving the + // earlier steps applied. The result lines are held back until the script is through, so none of + // them reports work that was rolled back. + let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut failed_at = 0; let outcome = s.grouped(&format!("run {script}"), |s| { - for (i, (cmd, params)) in steps.iter().enumerate() { + for (i, step) in steps.iter().enumerate() { failed_at = i; - let v = s.batch_step(&results, i, cmd, params)?; + s.batch_step(&mut scope, &step.command, &step.params)?; s.ui_requests.clear(); - results.push(v); } Ok(()) }); if let Err(e) = outcome { - let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } - for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { - println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); + for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() { + println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; + refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?; if ext(out) == "figcraft" { // Through the engine, so the version history is kept. write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?; @@ -413,6 +468,9 @@ mod tests { assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err()); assert!(parse_script("not json\n").is_err()); assert!(parse_script("42").is_err()); + // `args` for `params` would run the step without them (#1413). + let e = parse_script(r#"[{"command":"a"},{"command":"b","args":{"x":1}}]"#).unwrap_err().to_string(); + assert!(e.contains("step 1: unknown key `args`"), "{e}"); } #[test] @@ -511,6 +569,43 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A script names its steps with `as` and reads paths into their results, like `command.batch`. + #[test] + fn a_script_refers_to_named_steps_and_paths() { + let dir = temp_dir("run-ref"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let top = doc.node(s.page().unwrap()).unwrap().children.clone(); + let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect(); + assert_eq!(names, ["Card", "Copy"]); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Two steps with one name fail before anything runs. + #[test] + fn a_script_with_a_name_twice_runs_nothing() { + let dir = temp_dir("run-dup-name"); + let script = dir.join("script.json"); + std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap(); + let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") }; + assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// A step that fails takes the whole script back: nothing is written, and the failure says so. #[test] fn a_failed_step_leaves_no_output() { @@ -530,4 +625,24 @@ mod tests { assert!(!out.exists(), "a script that failed writes nothing"); std::fs::remove_dir_all(dir).unwrap(); } + + #[test] + fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() { + let dir = temp_dir("script-images-left-out"); + let input = dir.join("big.figcraft"); + std::fs::write(&input, b"kept").unwrap(); + let link = dir.join("link.figcraft"); + std::fs::hard_link(&input, &link).unwrap(); + let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft")); + let duplicate = Session::new(); + let active = duplicate.ds().unwrap(); + + for out in [input, link] { + let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out); + assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}"); + } + assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok()); + assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok()); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 666276731..905a588e0 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -17,8 +17,8 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window USAGE: figcraft-cli info [--json] - figcraft-cli export -o [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] - [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] + figcraft-cli export -o [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME] + [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore] figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] @@ -33,11 +33,11 @@ Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): te fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are indexed the first time text is laid out, as in the app. -Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what -wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file -per top-level frame into the directory, or one file when has an image extension or --node -is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may -not open every file); PDFs take print options (a bleed like 3mm or 0.125in, +Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what +wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an +image layer). `export` writes one file per top-level frame into the directory, or one file when + has an image extension or --node is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may +not open every file; `penpot`, or an ending in .penpot: the whole file as a Penpot 2.x archive); PDFs take print options (a bleed like 3mm or 0.125in, crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of {\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step. `plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) @@ -108,15 +108,27 @@ impl From for CliError { pub type CliResult = Result; -fn run(argv: &[String]) -> CliResult { - let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts"); - let argv: Vec = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect(); - if system_fonts { +fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { + let flags = [flags, &["no-system-fonts"]].concat(); + let args = Args::parse(argv, valued, &flags)?; + if !args.flag("no-system-fonts") { // Text lays out with the installed fonts, like the desktop app, so headless renders and // auto layout sizes match what users see. figcraft_text::scan_system_fonts_on_demand(); + } else { + // Nothing scans them then, not even a `.sketch` import matching its fonts. + figcraft_text::opt_out_of_system_fonts(); } - let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; + Ok(args) +} + +fn run(argv: &[String]) -> CliResult { + let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else { + return Err(CliError::Usage("missing subcommand".into())); + }; + let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?; + // Keep leading global flags with the subcommand's options, where values and `--` are parsed. + let rest: Vec = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect(); match cmd.as_str() { "help" | "--help" | "-h" => { println!("{USAGE}"); @@ -127,21 +139,27 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; + let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; confined(&a, || files::info(&file, a.flag("json"))) } "export" => { - let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; + let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat(); + let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".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" => { - let a = Args::parse(rest, &ROOT_FLAGS, &[])?; + let a = parse_args(&rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), @@ -149,19 +167,19 @@ fn run(argv: &[String]) -> CliResult { } "render" => { let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let script = a.one_positional("run ")?; confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { - let a = Args::parse(rest, &["filter"], &["json"])?; + let a = parse_args(&rest, &["filter"], &["json"])?; if !a.positional.is_empty() { return Err(CliError::Usage("commands takes no positional arguments".into())); } @@ -170,7 +188,7 @@ fn run(argv: &[String]) -> CliResult { } "mcp" => { let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } @@ -185,7 +203,7 @@ fn run(argv: &[String]) -> CliResult { mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { - let a = Args::parse(rest, &["server"], &[])?; + let a = parse_args(&rest, &["server"], &[])?; match a.positional.as_slice() { [verb, folder] if verb == "publish" => { let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?; @@ -195,10 +213,10 @@ fn run(argv: &[String]) -> CliResult { } } "perf" => { - let a = Args::parse(rest, &["layers", "instances"], &[])?; + let a = parse_args(&rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); let instances: Vec = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); - perf::run(&sizes, &instances, system_fonts) + perf::run(&sizes, &instances, !a.flag("no-system-fonts")) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/tests/it/cli.rs b/apps/figcraft-cli/tests/it/cli.rs index b1aeb54a9..ae00ceb70 100644 --- a/apps/figcraft-cli/tests/it/cli.rs +++ b/apps/figcraft-cli/tests/it/cli.rs @@ -13,6 +13,72 @@ fn tmp(name: &str) -> std::path::PathBuf { d } +/// Conversion and image commands must report the Penpot loader's notes too. +#[test] +fn penpot_import_notes_reach_convert_export_and_render() { + use figcraft_doc::proto::{Action, Easing, Interaction, Transition, Trigger}; + use figcraft_doc::{Document, NodeKind}; + let d = tmp("penpot-notes"); + let mut doc = Document::new("Prototype"); + let page = doc.first_page().unwrap(); + let home = doc.create(page, NodeKind::Frame, "Home", 0.0, 0.0, 40.0, 40.0).unwrap(); + let dest = doc.create(page, NodeKind::Frame, "Next", 60.0, 0.0, 40.0, 40.0).unwrap(); + doc.node_mut(home).unwrap().interactions.push(Interaction { + trigger: Trigger::OnClick, + actions: vec![Action::Navigate { dest, preserve_scroll: false }], + transition: Transition::Instant, + duration_ms: 0, + easing: Easing::Linear, + delay_ms: 0, + }); + let input = d.join("source.penpot"); + std::fs::write(&input, figcraft_format::penpot::export(&doc).unwrap().0).unwrap(); + for (command, name) in [("convert", "out.figcraft"), ("export", "out.svg"), ("render", "out.png")] { + let mut cmd = bin(); + cmd.arg(command).arg(&input); + if command != "convert" { + cmd.arg("-o"); + } + let out = cmd.arg(d.join(name)).output().unwrap(); + let notes = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{command}: {notes}"); + assert!(notes.contains("note: Imported 1 Penpot prototype connection"), "{command}: {notes}"); + } + let _ = std::fs::remove_dir_all(d); +} + +#[test] +fn option_terminator_allows_dash_prefixed_filenames() { + let dir = tmp("option-terminator"); + let doc = figcraft_doc::Document::new("Dash-prefixed file"); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] { + std::fs::write(dir.join(name), &bytes).unwrap(); + let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap(); + assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr)); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["file"], name); + assert_eq!(info["name"], "Dash-prefixed file"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn global_font_flag_respects_option_values() { + for filter in ["--", "--no-system-fonts"] { + let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap(); + assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr)); + assert!(serde_json::from_slice::(&output.stdout).unwrap().is_array()); + } +} + +#[test] +fn global_font_flag_can_precede_the_subcommand() { + let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(!serde_json::from_slice::>(&output.stdout).unwrap().is_empty()); +} + #[test] fn bad_arguments_exit_2_without_panicking() { for args in [ diff --git a/apps/figcraft-cli/tests/it/fonts.rs b/apps/figcraft-cli/tests/it/fonts.rs index a48056bdb..998665c48 100644 --- a/apps/figcraft-cli/tests/it/fonts.rs +++ b/apps/figcraft-cli/tests/it/fonts.rs @@ -66,3 +66,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() { let shown = String::from_utf8_lossy(&out.stdout); assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); } + +/// A synthetic `.sketch` file: one page with one Helvetica text. +const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA="; + +/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to +/// `--no-system-fonts` too: the run still knows only the built-in families. +#[test] +fn sketch_imports_keep_to_no_system_fonts() { + let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let script = d.join("open.json"); + let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]); + std::fs::write(&script, steps.to_string()).unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli")) + .arg("run") + .arg(&script) + .arg("--no-system-fonts") + .env_remove("FIGCRAFT_NO_SYSTEM_FONTS") + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let lines: Vec = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines[0]["ok"], json!(true), "{lines:?}"); + let fonts: Vec = serde_json::from_value(lines[1]["result"].clone()).unwrap(); + assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts"); +} diff --git a/apps/figcraft-cli/tests/it/main.rs b/apps/figcraft-cli/tests/it/main.rs index ae4b0488f..2e628773d 100644 --- a/apps/figcraft-cli/tests/it/main.rs +++ b/apps/figcraft-cli/tests/it/main.rs @@ -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; diff --git a/apps/figcraft-cli/tests/it/vector_drawable.rs b/apps/figcraft-cli/tests/it/vector_drawable.rs new file mode 100644 index 000000000..b99c32cd7 --- /dev/null +++ b/apps/figcraft-cli/tests/it/vector_drawable.rs @@ -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(" 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"); diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index d5f4f761e..01ef3cbda 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -39,6 +39,9 @@ pub struct Config { pub compact_after: usize, /// …or once the log is this large (MiB). pub compact_mb: u64, + /// Most MiB of recent batches a file in use keeps in memory for apps that reconnect (one + /// further behind catches up with the whole file). + pub max_log_mb: u64, /// Most plugin versions the plugin directory keeps (0 turns it off). pub max_plugins: usize, /// Largest plugin bundle, in MiB. @@ -84,6 +87,7 @@ impl Default for Config { max_people: 256, compact_after: 1000, compact_mb: 16, + max_log_mb: 64, max_plugins: 500, max_plugin_mb: 16, max_plugins_mb: 1024, @@ -209,6 +213,7 @@ impl Config { ("max_people", self.max_people as u64), ("compact_after", self.compact_after as u64), ("compact_mb", self.compact_mb), + ("max_log_mb", self.max_log_mb), ("max_plugin_mb", self.max_plugin_mb), ("max_plugins_mb", self.max_plugins_mb), ] { @@ -216,7 +221,7 @@ impl Config { return Err(format!("`{name}` must be at least 1")); } } - if self.file_mb() > 4096 || self.compact_mb > 4096 { + if self.file_mb() > 4096 || self.compact_mb > 4096 || self.max_log_mb > 4096 { return Err("sizes are in MiB and at most 4096".into()); } // A bundle travels in one message (at most 64 MiB) next to its entry. @@ -274,17 +279,19 @@ impl Config { usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX) } - /// The collaboration core's part: when open, messages as large as a file at most, and the - /// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a - /// quarter of a file's bytes (256 KiB at least). + /// The collaboration core's part: the batches kept for reconnecting clients (in memory, and + /// in the file's snapshot) at most `max_log_mb`; when open, messages as large as a file at + /// most, and those batches also at most a quarter of a file's bytes (256 KiB at least). fn core(&self) -> figcraft_collab::Limits { - let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }; + let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX); + let base = + figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() }; if !self.open_publish { return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } let max = self.open_message(); - let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX); - figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base } + let log = (bytes(self.file_mb()) / 4).max(256 << 10).min(base.max_log_bytes); + figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: log, ..base } } /// The transport's part. A private server keeps the defaults; open to the public, connections diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index d32a17472..312b4aea5 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -128,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option { /// A snapshot file's bytes. pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result, String> { - let body = message::encode(state).map_err(|e| e.to_string())?; - let mut out = Vec::with_capacity(SNAP_HEADER + body.len()); + let mut out = Vec::with_capacity(SNAP_HEADER); out.extend_from_slice(SNAP_MAGIC); out.push(FORMAT); out.extend_from_slice(&generation.to_le_bytes()); - out.extend_from_slice(&crc(&body).to_le_bytes()); - out.extend_from_slice(&body); + out.extend_from_slice(&0u32.to_le_bytes()); + let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?; + let sum = crc(&out[SNAP_HEADER..]); + out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes()); Ok(out) } diff --git a/apps/figcraft-server/src/library_tests.rs b/apps/figcraft-server/src/library_tests.rs index f4703f20d..5dd45435b 100644 --- a/apps/figcraft-server/src/library_tests.rs +++ b/apps/figcraft-server/src/library_tests.rs @@ -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. diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index dc86d1232..37cbabe15 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -584,6 +584,19 @@ fn files_above_64_mib_fit_every_limit() { assert_eq!(NetLimits::default().max_server_message, 1 << 30); } +#[test] +fn the_batches_kept_for_reconnecting_apps_follow_max_log_mb() { + let log = |cfg: Config| cfg.store().core.max_log_bytes; + assert_eq!(log(Config::default()), 64 << 20, "a private server's log is bounded too"); + assert_eq!(log(Config { max_log_mb: 8, ..Config::default() }), 8 << 20); + // Open to the public: also at most a quarter of a file (16 MiB unless set). + assert_eq!(log(Config { open_publish: true, ..Config::default() }), 4 << 20); + assert_eq!(log(Config { open_publish: true, max_log_mb: 1, ..Config::default() }), 1 << 20); + for mb in [0, 4097] { + assert!(Config { max_log_mb: mb, ..Config::default() }.check().is_err(), "max_log_mb = {mb}"); + } +} + #[test] fn large_files_warn_the_operator() { let public_url = Some("wss://figcraft.example.com/".into()); diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ab65c2d8a..90fce92ba 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -52,6 +52,14 @@ use crate::codes::{self, Sealed}; use crate::directory::{Directory, DirectoryConfig}; use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE}; +mod backend; +mod files; +mod journal; +mod library; +mod links; + +pub use library::library_name; + pub const LOCK: &str = "LOCK"; pub const KEY: &str = "server.key"; pub const FILES: &str = "files"; @@ -541,844 +549,6 @@ impl Store { people: open.map_or(0, |o| o.people), } } - - /// 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. - fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { - let blocks_of = |text: &str| text.parse::().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, 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, - fits: &dyn Fn(u64) -> Result<(), String>, - ) -> Result<(FileInfo, NewLink, NewLink, Option), 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::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) -> Vec { - 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). - 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 { - let t = now(); - self.next_sweep = t.saturating_add(SWEEP_EVERY); - let due: Vec = - self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); - for id in &due { - match self.remove(id) { - Ok(()) => log::info!("event=expired file={id}"), - Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"), - } - } - due - } - - /// Make a file look unused for `secs` more (tests: expiry without waiting). - #[cfg(test)] - pub fn age(&mut self, file: &str, secs: u64) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - let m = &mut e.meta; - (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); - let _ = write_json(&path, m); - } - } - - /// Make an open file's log refuse writes (tests: a full or failing disk). - #[cfg(test)] - pub fn break_log(&mut self, file: &str) { - let path = self.dir(file).join(LOG); - if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut()) - && let Ok(f) = File::open(path) - { - log.file = f; - } - } - - /// Note that a file was opened now (it is used: it doesn't expire). - 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). - 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 { - let ip = ip_key(ip.trim().parse::().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 = 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 { - 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(()) - } - - /// The files kept. - pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { - 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, 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 { - 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). - fn link_of(entry: &Entry, token: &str) -> Option { - 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) { - 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 { - 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(()) - } - - /// 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). - fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { - 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 { - 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, 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, 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). - 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); - } - } - } - - /// 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). - 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). - fn load(&mut self, file: &str) -> Result { - 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. - 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 { - 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. - fn low_disk(&mut self) -> Option { - 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. - 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. - 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. - fn flush_all(&mut self) -> Vec { - 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 - } } /// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced @@ -1388,38 +558,6 @@ struct RewriteError { snapshot_replaced: bool, } -/// 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). -fn flush_log(id: &str, log: &mut Log) -> Result { - 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(()) - } - } -} - -/// 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) } -} - fn write_json(path: &Path, value: &T) -> Result<(), String> { let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; disk::write_atomic(path, &text).map_err(|e| e.to_string()) @@ -1430,239 +568,3 @@ fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), let token = token::secret()?; Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token)) } - -impl Backend for Store { - fn admit(&mut self, file: &str, token: &str) -> Result { - 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 { - 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 { - 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 dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| { - let _ = flush_log(file, log); - log.dirty - }); - if dirty { - self.compact(file, server); - } - // It closes anyway, everything in its snapshot: a compaction cut short needs no report. - 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 { - Some(self.host_info(Some(file))) - } - - fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { - 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) -> 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) -> 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 - } -} diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs new file mode 100644 index 000000000..95fd89483 --- /dev/null +++ b/apps/figcraft-server/src/store/backend.rs @@ -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 { + 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 { + 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 { + 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 { + Some(self.host_info(Some(file))) + } + + fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { + 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) -> 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) -> 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 + } +} diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs new file mode 100644 index 000000000..565916ed2 --- /dev/null +++ b/apps/figcraft-server/src/store/files.rs @@ -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::().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, 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, + fits: &dyn Fn(u64) -> Result<(), String>, + ) -> Result<(FileInfo, NewLink, NewLink, Option), 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) -> Vec { + 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 { + let t = now(); + self.next_sweep = t.saturating_add(SWEEP_EVERY); + let due: Vec = + 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 { + let ip = ip_key(ip.trim().parse::().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 = 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 { + 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(()) + } +} diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs new file mode 100644 index 000000000..5f8904fca --- /dev/null +++ b/apps/figcraft-server/src/store/journal.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(()) + } + } +} diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs new file mode 100644 index 000000000..55ca50add --- /dev/null +++ b/apps/figcraft-server/src/store/library.rs @@ -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) -> Result { + 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 { + 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, 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, 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) } +} diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs new file mode 100644 index 000000000..6c279219f --- /dev/null +++ b/apps/figcraft-server/src/store/links.rs @@ -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) -> Vec { + 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, 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 { + 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 { + 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) { + 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 { + 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(()) + } +} diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index d8c5b9155..93a872ee1 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() { assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows"); } +#[test] +fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() { + let dir = TempDir::new("read-only-reconnects"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let (id, original) = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Read only"); + let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(); + (id, bytes) + }; + let mut last_client = 0; + for _ in 0..6 { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let mut server = store.open(&id).unwrap(); + let client = join(&mut server, Access::View); + assert!(client.id() > last_client, "issued ids survive closing and reopening the store"); + last_client = client.id(); + store.changed(&id, &mut server); + // Idle must sync even if no periodic flush ran before the connection closed. + assert!(store.idle(&id, &mut server)); + assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot"); + assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original); + let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap(); + assert!(!log.entries.is_empty()); + assert_eq!(server.seq(), 0); + } +} + #[test] fn a_long_log_is_compacted_while_open() { let dir = TempDir::new("compact"); @@ -252,6 +280,19 @@ fn temporary_directories_are_removed() { assert!(!path.exists()); } +#[test] +fn snapshot_encoding_keeps_the_existing_framing_and_checksum() { + let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap(); + let body = figcraft_collab::message::encode(&state).unwrap(); + let mut legacy = b"FCSN\x01".to_vec(); + legacy.extend_from_slice(&37u64.to_le_bytes()); + legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes()); + legacy.extend_from_slice(&body); + let encoded = disk::encode_snapshot(37, &state).unwrap(); + assert_eq!(encoded, legacy); + assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state)); +} + #[test] fn random_bytes_never_panic_the_readers() { let mut rng = Rng::new(7); diff --git a/apps/figcraft-web/.cargo/config.toml b/apps/figcraft-web/.cargo/config.toml new file mode 100644 index 000000000..ebffd7002 --- /dev/null +++ b/apps/figcraft-web/.cargo/config.toml @@ -0,0 +1,6 @@ +# The browser build (trunk runs cargo from this folder; packaging/web/package.sh). With simd128 the +# glyph rasterizer's SIMD code compiles for wasm SIMD instead of its much larger scalar fallback: +# about 1.6 MB less wasm. Every current browser has wasm SIMD (Safari since 16.4). The plugin +# workspace is built from the repository root, so its modules stay without SIMD for the plugin runtime. +[target.wasm32-unknown-unknown] +rustflags = ["-Ctarget-feature=+simd128"] diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index ffd44f3bc..aa0fdffbd 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -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"] } diff --git a/apps/figcraft-web/index.html b/apps/figcraft-web/index.html index 1a9969d5c..1915a9a20 100644 --- a/apps/figcraft-web/index.html +++ b/apps/figcraft-web/index.html @@ -2,12 +2,12 @@ - + FigCraft - + \n", out.markup, out.tokens.root_rule(), out.styles) -} - -/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!` -/// markup (text as Rust string literals); the last two with component parameters and instances -/// as calls. With its stylesheet. -pub(crate) struct Markup<'c, 'a> { - ctx: &'c Ctx<'a>, - /// Html, React or Leptos. - lang: CodeLang, - used: Vec, - pub markup: String, - pub styles: String, - pub tokens: Tokens, - /// The root's class (default: from its name) and extra JSX attributes. - pub root_class: Option, - pub root_attrs: String, - /// Each layer's path in the tree (names with sibling counts), class and declarations, so - /// variants can be compared layer by layer. - pub rules: Vec<(String, String, String)>, -} - -impl<'c, 'a> Markup<'c, 'a> { - pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self { - Markup { - ctx, - lang, - used: Vec::new(), - markup: String::new(), - styles: String::new(), - tokens: Tokens::default(), - root_class: None, - root_attrs: String::new(), - rules: Vec::new(), - } - } - - /// JSX indents by two spaces, HTML and Leptos by four. - fn pad(&self, depth: usize) -> String { - if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) } - } - - /// Component code (React or Leptos): parameters and instances as calls. - fn code(&self) -> bool { - self.lang != CodeLang::Html - } - - /// A parameter's name in the generated code. - fn ident(&self, p: &Param) -> String { - if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() } - } - - /// A layer, shown only while its boolean parameter is on (component code). - pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) { - let Some(n) = self.ctx.doc.node(id) else { return }; - if self.code() - && id != self.ctx.root - && let Some(p) = self.ctx.param(n, "visible") - { - let (pad, p) = (self.pad(depth), self.ident(p)); - let (open, close) = - if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") }; - self.markup.push_str(&format!("{pad}{open}\n")); - self.element(n, parent, depth + 1, path); - self.markup.push_str(&format!("{pad}{close}\n")); - return; - } - self.element(n, parent, depth, path); - } - - fn rule(&mut self, path: &str, class: &str, decls: String) { - self.styles.push_str(&format!(".{class} {{\n")); - for line in decls.lines() { - self.styles.push_str(&format!(" {line}\n")); - } - self.styles.push_str("}\n"); - self.rules.push((path.to_string(), class.to_string(), decls)); - } - - fn element(&mut self, n: &Node, parent: Option<&Node>, depth: usize, path: &str) { - let doc = self.ctx.doc; - let root = n.id == self.ctx.root; - let class = match self.root_class.take().filter(|_| root) { - Some(c) => { - self.used.push(c.clone()); - c - } - None => class_name(&n.name, &mut self.used), - }; - let pad = self.pad(depth); - let attr = if self.lang == CodeLang::React { "className" } else { "class" }; - // How it sits in its parent: in the flex flow, or placed at its position. - let mut decls = String::new(); - let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; - if root { - decls.push_str("position: relative;\n"); - } else if in_flow { - let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); - let (main, cross) = - if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; - if main == Sizing::Fill { - decls.push_str("flex: 1 1 0;\n"); - } - if cross == Sizing::Fill { - decls.push_str("align-self: stretch;\n"); - } - } else { - let c = n.transform.as_coeffs(); - decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 0.01 { - decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); - } - } - // Nested instances are calls (component code), in a wrapper that places them when needed. - if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) { - let at = depth + usize::from(!decls.is_empty()); - let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" }; - let inner = match self.ctx.param(n, "mainComponent") { - Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))), - None => self.ctx.call(n, self.lang, at), - }; - if let Some(inner) = inner { - if decls.is_empty() { - self.markup.push_str(&inner); - } else { - self.markup.push_str(&format!("{pad}
\n{inner}{pad}
\n")); - self.rule(path, &class, decls); - } - return; - } - } - let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)); - let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug); - let mut own = css_decls(doc, n, &mut self.tokens, hug); - if n.layout.is_none() && !n.children.is_empty() && !root && in_flow { - own.push_str("position: relative;\n"); - } - decls.push_str(&own); - self.rule(path, &class, decls); - let attrs = if root { self.root_attrs.clone() } else { String::new() }; - let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>"); - match &n.kind { - NodeKind::Text(t) => { - let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) { - (Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)), - (Some(p), _) => format!("{{{}}}", p.ident), - (None, CodeLang::React) => jsx_text(&t.characters), - // `view!` takes text as string literals and void elements self-closed. - (None, CodeLang::Leptos) => { - t.characters.chars().take(10_000).collect::().lines().map(|l| format!("{l:?}")).collect::>().join("
") - } - (None, _) => html_escape(&t.characters), - }; - self.markup.push_str(&format!("{}{body}

\n", open("p"))); - } - _ => { - let kids = self.ctx.kids(n); - let slot = self.ctx.param(n, "slot").filter(|_| self.code()); - let leptos = self.lang == CodeLang::Leptos; - let open = open("div"); - if let Some(p) = slot.filter(|_| kids.is_empty()) { - let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() }; - self.markup.push_str(&format!("{open}{{{content}}}\n")); - } else if kids.is_empty() || self.ctx.too_deep(depth) { - self.markup.push_str(&format!("{open}\n")); - } else { - self.markup.push_str(&format!("{open}\n")); - // A slot shows the instance's content, or else the main's. - let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2)); - let inner = match slot { - Some(p) if leptos => { - self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p))); - depth + 2 - } - Some(p) => { - self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident)); - depth + 3 - } - None => depth + 1, - }; - let mut seen: Vec<&str> = Vec::new(); - // Bottom layer first: later elements paint on top, like the layer order. - for k in kids { - let Some(kn) = doc.node(k) else { continue }; - let count = seen.iter().filter(|s| **s == kn.name).count(); - seen.push(&kn.name); - self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name)); - } - match slot { - Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")), - Some(_) => self.markup.push_str(&format!("{p2}\n{p1})}}\n")), - None => {} - } - self.markup.push_str(&format!("{pad}\n")); - } - } - } - } -} - -// ------------------------------------------------------------------ SwiftUI - -fn swift_color(c: Color) -> String { - format!("Color(red: {}, green: {}, blue: {}, opacity: {})", num(f64::from(c.r)), num(f64::from(c.g)), num(f64::from(c.b)), num(f64::from(c.a))) -} - -fn swift_weight(w: u16) -> &'static str { - match w { - 0..=150 => ".thin", - 151..=250 => ".ultraLight", - 251..=350 => ".light", - 351..=450 => ".regular", - 451..=550 => ".medium", - 551..=650 => ".semibold", - 651..=750 => ".bold", - 751..=850 => ".heavy", - _ => ".black", - } -} - -/// SwiftUI for a layer and its subtree at `depth`; `extra` modifiers go last (a free child's -/// offset). A layer a boolean parameter shows sits in an `if`. -pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) { - let Some(n) = ctx.doc.node(id) else { return }; - if id != ctx.root - && let Some(p) = ctx.param(n, "visible") - { - let pad = indent(depth); - out.push_str(&format!("{pad}if {} {{\n", p.ident)); - swift_layer(ctx, n, depth + 1, out, extra); - out.push_str(&format!("{pad}}}\n")); - return; - } - swift_layer(ctx, n, depth, out, extra); -} - -/// A layer name safe for a single-line `//` comment: line breaks become spaces. -fn one_line(name: &str) -> String { - name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ") -} - -fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { - let doc = ctx.doc; - let pad = indent(depth); - if ctx.too_deep(depth) { - out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); - return; - } - // Nested instances are calls; an instance swap parameter is a view. - let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) { - Some(p) => Some(format!("{pad}{}\n", p.ident)), - None => ctx.call(n, CodeLang::SwiftUi, depth), - }; - if let Some(call) = call { - out.push_str(&call); - for m in extra { - out.push_str(&format!("{pad} {m}\n")); - } - return; - } - let mut mods: Vec = Vec::new(); - match &n.kind { - NodeKind::Text(t) => { - let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| p.ident.clone()); - out.push_str(&format!("{pad}Text({text})\n")); - mods.push(format!( - ".font(.custom({}, size: {}).weight({}))", - literal(&t.style.font_family, false), - num(t.style.font_size), - swift_weight(t.style.weight()) - )); - if let Some(c) = first_fill(n) { - mods.push(format!(".foregroundColor({})", swift_color(c))); - } - } - NodeKind::Ellipse { .. } => { - out.push_str(&format!("{pad}Ellipse()\n")); - if let Some(c) = first_fill(n) { - mods.push(format!(".fill({})", swift_color(c))); - } - mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height))); - } - NodeKind::Rectangle => { - out.push_str(&format!("{pad}RoundedRectangle(cornerRadius: {})\n", num(radius(n)))); - if let Some(c) = first_fill(n) { - mods.push(format!(".fill({})", swift_color(c))); - } - mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height))); - } - k if k.is_frame_like() || matches!(k, NodeKind::Group) => { - let children = ctx.kids(n); - let open = match n.layout.as_ref().map(|l| (l.mode, l)) { - Some((LayoutMode::Vertical, l)) => format!("VStack(alignment: .leading, spacing: {}) {{", num(l.gap)), - Some((_, l)) => format!("HStack(alignment: .center, spacing: {}) {{", num(l.gap)), - None => "ZStack(alignment: .topLeading) {".to_string(), - }; - out.push_str(&format!("{pad}{open}\n")); - // A slot shows the instance's content, or else the main's. - let slot = ctx.param(n, "slot"); - let inner = match slot { - Some(p) => { - out.push_str(&format!("{pad} if let {0} {{\n{pad} {0}\n{pad} }} else {{\n", p.ident)); - depth + 2 - } - None => depth + 1, - }; - for c in &children { - // Free children sit at their offset inside a ZStack. - let offset: Vec = match doc.node(*c).filter(|_| n.layout.is_none()) { - Some(cn) => { - let [_, _, _, _, x, y] = cn.transform.as_coeffs(); - vec![format!(".offset(x: {}, y: {})", num(x), num(y))] - } - None => Vec::new(), - }; - swift(ctx, *c, inner, out, &offset); - } - if slot.is_some() { - out.push_str(&format!("{pad} }}\n")); - } - out.push_str(&format!("{pad}}}\n")); - if let Some(l) = &n.layout { - let [t, r, b, lft] = l.padding; - if [t, r, b, lft].iter().any(|v| *v > 0.0) { - mods.push(format!(".padding(EdgeInsets(top: {}, leading: {}, bottom: {}, trailing: {}))", num(t), num(lft), num(b), num(r))); - } - } - mods.push(format!(".frame(width: {}, height: {}, alignment: .topLeading)", num(n.width), num(n.height))); - if let Some(c) = first_fill(n) { - mods.push(format!(".background({})", swift_color(c))); - } - if radius(n) > 0.0 { - mods.push(format!(".clipShape(RoundedRectangle(cornerRadius: {}))", num(radius(n)))); - } - } - _ => { - out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image({})\n", n.kind.label(), literal(&n.name, false))); - mods.push(".resizable()".into()); - mods.push(format!(".frame(width: {}, height: {})", num(n.width), num(n.height))); - } - } - if let Some(c) = stroke_color(n) { - mods.push(format!( - ".overlay(RoundedRectangle(cornerRadius: {}).stroke({}, lineWidth: {}))", - num(radius(n)), - swift_color(c), - num(n.stroke.weight) - )); - } - if let Some(s) = drop_shadow(n) { - mods.push(format!( - ".shadow(color: {}, radius: {}, x: {}, y: {})", - swift_color(s.color), - num(s.radius / 2.0), - num(s.offset.0), - num(s.offset.1) - )); - } - if n.opacity < 1.0 { - mods.push(format!(".opacity({})", num(f64::from(n.opacity)))); - } - mods.extend(extra.iter().cloned()); - for m in mods { - out.push_str(&format!("{pad} {m}\n")); - } -} - -// ------------------------------------------------------------------ Jetpack Compose - -fn kotlin_color(c: Color) -> String { - let [r, g, b, a] = c.to_rgba8(); - format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})") -} - -/// The Modifier chain for a layer (size, shadow, background, border, alpha, padding). -fn modifier(n: &Node, shape: &str, padding: bool) -> String { - let mut m = format!("Modifier.size({}.dp, {}.dp)", num(n.width), num(n.height)); - if let Some(s) = drop_shadow(n) { - m.push_str(&format!(".shadow({}.dp, {shape})", num(s.radius / 2.0))); - } - if let Some(c) = first_fill(n) { - m.push_str(&format!(".background({}, {shape})", kotlin_color(c))); - } - if let Some(c) = stroke_color(n) { - m.push_str(&format!(".border({}.dp, {}, {shape})", num(n.stroke.weight), kotlin_color(c))); - } - if n.opacity < 1.0 { - m.push_str(&format!(".alpha({}f)", num(f64::from(n.opacity)))); - } - if padding && let Some(l) = &n.layout { - let [t, r, b, lft] = l.padding; - if [t, r, b, lft].iter().any(|v| *v > 0.0) { - m.push_str(&format!(".padding(start = {}.dp, top = {}.dp, end = {}.dp, bottom = {}.dp)", num(lft), num(t), num(r), num(b))); - } - } - m -} - -/// Jetpack Compose for a layer and its subtree at `depth`. A layer a boolean parameter shows sits -/// in an `if`. -pub(crate) fn compose(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String) { - let Some(n) = ctx.doc.node(id) else { return }; - if id != ctx.root - && let Some(p) = ctx.param(n, "visible") - { - let pad = indent(depth); - out.push_str(&format!("{pad}if ({}) {{\n", p.ident)); - compose_layer(ctx, n, depth + 1, out); - out.push_str(&format!("{pad}}}\n")); - return; - } - compose_layer(ctx, n, depth, out); -} - -fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { - let doc = ctx.doc; - let pad = indent(depth); - if ctx.too_deep(depth) { - out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); - return; - } - // Nested instances are calls; an instance swap parameter is a composable. - let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) { - Some(p) => Some(format!("{pad}{}()\n", p.ident)), - None => ctx.call(n, CodeLang::Compose, depth), - }; - if let Some(call) = call { - out.push_str(&call); - return; - } - let shape = if radius(n) > 0.0 { format!("RoundedCornerShape({}.dp)", num(radius(n))) } else { "RectangleShape".to_string() }; - match &n.kind { - NodeKind::Text(t) => { - let color = first_fill(n).map(kotlin_color).unwrap_or_else(|| "Color.Unspecified".into()); - let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone()); - out.push_str(&format!( - "{pad}Text(\n{pad} text = {text},\n{pad} fontSize = {}.sp,\n{pad} fontWeight = FontWeight.W{},\n{pad} color = {color},\n{pad})\n", - num(t.style.font_size), - (t.style.weight().clamp(100, 900) / 100) * 100 - )); - } - NodeKind::Ellipse { .. } => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, "CircleShape", false))), - NodeKind::Rectangle => out.push_str(&format!("{pad}Box(modifier = {})\n", modifier(n, &shape, false))), - k if k.is_frame_like() || matches!(k, NodeKind::Group) => { - let children = ctx.kids(n); - let open = match n.layout.as_ref().map(|l| (l.mode, l)) { - Some((LayoutMode::Vertical, l)) => format!( - "Column(\n{pad} modifier = {},\n{pad} verticalArrangement = Arrangement.spacedBy({}.dp),\n{pad}) {{", - modifier(n, &shape, true), - num(l.gap) - ), - Some((_, l)) => format!( - "Row(\n{pad} modifier = {},\n{pad} horizontalArrangement = Arrangement.spacedBy({}.dp),\n{pad} verticalAlignment = Alignment.CenterVertically,\n{pad}) {{", - modifier(n, &shape, true), - num(l.gap) - ), - None => format!("Box(modifier = {}) {{", modifier(n, &shape, false)), - }; - out.push_str(&format!("{pad}{open}\n")); - // A slot shows the instance's content, or else the main's. - let slot = ctx.param(n, "slot"); - let base = match slot { - Some(p) => { - out.push_str(&format!("{pad} if ({0} != null) {{\n{pad} {0}()\n{pad} }} else {{\n", p.ident)); - depth + 1 - } - None => depth, - }; - for c in &children { - if n.layout.is_none() - && let Some(cn) = doc.node(*c) - { - let [_, _, _, _, x, y] = cn.transform.as_coeffs(); - out.push_str(&format!("{}Box(modifier = Modifier.offset({}.dp, {}.dp)) {{\n", indent(base + 1), num(x), num(y))); - compose(ctx, *c, base + 2, out); - out.push_str(&format!("{}}}\n", indent(base + 1))); - } else { - compose(ctx, *c, base + 1, out); - } - } - if slot.is_some() { - out.push_str(&format!("{pad} }}\n")); - } - out.push_str(&format!("{pad}}}\n")); - } - _ => out.push_str(&format!( - "{pad}// {}: export as an image asset\n{pad}Image(painter = painterResource(R.drawable.asset), contentDescription = {}, modifier = Modifier.size({}.dp, {}.dp))\n", - n.kind.label(), - literal(&n.name, true), - num(n.width), - num(n.height) - )), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Session; - use crate::params::id_of; - use serde_json::json; - - fn card(s: &mut Session) -> NodeId { - let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()["id"]).unwrap(); - s.execute("node.set", &json!({"id": f, "props": {"fills": ["#FFFFFF"], "cornerRadius": 12}})).unwrap(); - s.execute("text.create", &json!({"text": "Say \"hi\" $5\nnow", "x": 0, "y": 0, "parent": f})).unwrap(); - s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": f})).unwrap(); - s.execute("layout.add", &json!({"ids": [f]})).unwrap(); - f - } - - #[test] - fn swiftui_and_compose_for_an_auto_layout_card() { - let mut s = Session::new(); - let f = card(&mut s); - let doc = s.doc().unwrap(); - let sw = code_for(doc, f, CodeLang::SwiftUi); - assert!(sw.contains("Stack(alignment:"), "{sw}"); - assert!(sw.contains("Text(\"Say \\\"hi\\\" $5\\nnow\")"), "escaped: {sw}"); - assert!(sw.contains("Ellipse()")); - assert!(sw.contains(".clipShape(RoundedRectangle(cornerRadius: 12))")); - let kt = code_for(doc, f, CodeLang::Compose); - assert!(kt.contains("Arrangement.spacedBy("), "{kt}"); - assert!(kt.contains("\\$5"), "Kotlin escapes $: {kt}"); - assert!(kt.contains("CircleShape")); - assert!(kt.contains("RoundedCornerShape(12.dp)")); - let css = code_for(doc, f, CodeLang::Css); - assert!(css.contains("display: flex;") && css.contains("border-radius: 12px;")); - // Through the command. - let v = s.execute("inspect.code", &json!({"id": f, "language": "compose"})).unwrap(); - assert_eq!(v["code"].as_str().unwrap(), kt); - assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err()); - } - - #[test] - fn free_children_get_offsets_and_deep_trees_are_capped() { - let mut s = Session::new(); - let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap(); - let root = parent; - s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap(); - for _ in 0..20 { - parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap(); - } - let doc = s.doc().unwrap(); - let sw = code_for(doc, root, CodeLang::SwiftUi); - assert!(sw.contains(".offset(x: 7, y: 9)"), "{sw}"); - assert!(sw.contains("…"), "depth capped"); - let kt = code_for(doc, root, CodeLang::Compose); - assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); - assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); - } - - #[test] - fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() { - let mut s = Session::new(); - let name = "deep\nevil()\r\u{2028}more()"; - let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap(); - let root = parent; - for _ in 0..16 { - parent = - id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"]) - .unwrap(); - } - let doc = s.doc().unwrap(); - for lang in [CodeLang::SwiftUi, CodeLang::Compose] { - let code = code_for(doc, root, lang); - assert!(code.contains("// deep evil() more() …\n"), "{code}"); - for line in code.lines() { - let t = line.trim_start(); - assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}"); - } - } - } -} diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 5b7593919..d53eeff26 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() { let (f, r) = frame_and_rect(room.server.document()); let start = room.server.replica().clone(); let cmds = vec![ - ("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})), - ("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})), - ("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})), + crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})), + crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})), + crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})), ]; room.s(0).batch("Three", &cmds).unwrap(); room.pump(); diff --git a/crates/engine/src/comment_agent_tests.rs b/crates/engine/src/comment_agent_tests.rs new file mode 100644 index 000000000..f5a5b6592 --- /dev/null +++ b/crates/engine/src/comment_agent_tests.rs @@ -0,0 +1,78 @@ +//! Comments from an AI agent (#1414): a command run as an agent (`Session::execute_as`, which the +//! control channel's and MCP's `engine.execute {agent}` use) signs comments with the agent's own +//! name and marks them as an agent's; the user's own comments stay as they were. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::comment::agent_author; +use crate::live::initials; + +fn as_agent(s: &mut Session, agent: &str, cmd: &str, p: Value) -> Value { + s.execute_as(Some(agent), cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn thread(s: &mut Session, agent: Option<&str>) -> Value { + s.execute_as(agent, "comment.list", &json!({"filter": "all"})).unwrap()["comments"][0].clone() +} + +#[test] +fn an_agents_comments_carry_its_name_and_the_agent_marker() { + let mut s = Session::new(); + let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "@Claude make this bigger"})).unwrap()["id"].as_u64().unwrap(); + as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "Done"})); + let t = thread(&mut s, None); + assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("You"), Some(false)), "the user's own comment is unchanged: {t}"); + let r = &t["replies"][0]; + assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Claude (agent)"), Some(true)), "{r}"); + let doc = s.doc().unwrap(); + let m = &doc.comment(c).unwrap().replies[0]; + assert!(m.agent && m.author == "Claude (agent)"); + // The agent acts as itself again later: no `author` on any call. + as_agent(&mut s, "Claude", "comment.react", json!({"id": c, "emoji": "👍"})); + assert_eq!(s.doc().unwrap().comment(c).unwrap().first.reactions[0].users, ["Claude (agent)"]); + assert_eq!(as_agent(&mut s, "Claude", "comment.people", json!({}))["you"], "Claude (agent)"); + assert_eq!(s.execute("comment.people", &json!({})).unwrap()["you"], "You", "the user's name never changes"); + assert_eq!(s.agent, None, "the agent acts only for its own call"); +} + +#[test] +fn an_agents_read_state_is_its_own() { + let mut s = Session::new(); + let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Look"})).unwrap()["id"].as_u64().unwrap(); + as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "On it"})); + assert_eq!(thread(&mut s, None)["unread"], true, "the agent's reply is unread for the user"); + as_agent(&mut s, "Claude", "comment.markRead", json!({})); + assert_eq!(thread(&mut s, None)["unread"], true, "the agent reading it doesn't read it for the user"); + assert_eq!(thread(&mut s, Some("Claude"))["unread"], false); +} + +#[test] +fn an_agent_without_a_name_is_still_not_the_user() { + let mut s = Session::new(); + as_agent(&mut s, "", "comment.add", json!({"x": 0, "y": 0, "text": "Hi"})); + let t = thread(&mut s, None); + assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("AI agent"), Some(true)), "{t}"); + // An explicit `author` still wins, and the message is still an agent's. + as_agent(&mut s, "Claude", "comment.reply", json!({"id": t["id"], "text": "x", "author": "Helper"})); + let r = &thread(&mut s, None)["replies"][0]; + assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Helper"), Some(true)), "{r}"); +} + +#[test] +fn agent_names_are_cleaned() { + assert_eq!(agent_author(" Claude "), "Claude (agent)"); + assert_eq!(agent_author("\u{7}\n "), "AI agent"); + assert_eq!(agent_author("a\u{0}b"), "ab (agent)"); + let long = "x".repeat(500); + assert_eq!(agent_author(&long).chars().count(), 64 + " (agent)".len()); +} + +#[test] +fn an_agents_avatar_shows_its_names_initial() { + assert_eq!(initials("Claude (agent)"), "C"); + assert_eq!(initials("AI agent"), "AA"); + assert_eq!(initials("Ada Lovelace"), "AL"); + assert_eq!(initials("(x) [y]"), "([", "no word starts with a letter: as before"); + assert_eq!(initials(" "), "?"); +} diff --git a/crates/engine/src/comment_mentions_tests.rs b/crates/engine/src/comment_mentions_tests.rs new file mode 100644 index 000000000..b25a7934e --- /dev/null +++ b/crates/engine/src/comment_mentions_tests.rs @@ -0,0 +1,44 @@ +//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights +//! them with: resolved against the file's people where it can be, and whether the name is you. + +use serde_json::{Value, json}; + +use crate::Session; + +fn mentions(s: &mut Session, p: Value) -> Vec { + let l = s.execute("comment.list", &p).unwrap(); + let c = &l["comments"][0]; + [&c["mentions"], &c["replies"][0]["mentions"]].into_iter().cloned().collect() +} + +#[test] +fn a_message_lists_the_names_it_mentions_and_not_an_email_address() { + let mut s = Session::new(); + let id = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Hi", "author": "Ada Lovelace"})).unwrap()["id"].as_u64().unwrap(); + let text = "@ada lovelace and @You: please check, or ask @Bob (mail a@b.com). Thanks @Ada Lovelace"; + s.execute("comment.reply", &json!({"id": id, "text": text, "author": "Sam"})).unwrap(); + let [first, reply] = <[Value; 2]>::try_from(mentions(&mut s, json!({}))).unwrap(); + assert_eq!(first, json!([])); + assert_eq!( + reply, + json!([ + {"name": "Ada Lovelace", "known": true, "you": false}, + {"name": "You", "known": true, "you": true}, + {"name": "Bob", "known": false, "you": false}, + ]), + "two people the file knows (once each, as the file spells them), one name it doesn't, no email" + ); + // `author` says whose list it is: then Ada is "you". + let reply = mentions(&mut s, json!({"author": "Ada Lovelace"})).remove(1); + assert_eq!(reply[0], json!({"name": "Ada Lovelace", "known": true, "you": true})); +} + +#[test] +fn the_mention_rule_matches_the_highlighting_rule() { + let people = ["Sam", "Sam Lee"]; + let found = figcraft_doc::mentions("@Sam Lee, @sam, x@Sam, @Zoe!", &people); + let names: Vec<_> = found.iter().map(|(r, who)| (r.clone(), *who)).collect(); + assert_eq!(names, vec![(0..8, Some("Sam Lee")), (10..14, Some("Sam")), (23..27, None)]); + assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]); + assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty()); +} diff --git a/crates/engine/src/comments_polish_tests.rs b/crates/engine/src/comments_polish_tests.rs index 42d319b33..f064d33ab 100644 --- a/crates/engine/src/comments_polish_tests.rs +++ b/crates/engine/src/comments_polish_tests.rs @@ -296,3 +296,159 @@ fn next_steps_through_the_list_across_pages() { assert!(!comment(&s, a).is_unread("You") && !comment(&s, b).is_unread("You")); assert!(s.execute("comment.next", &json!({"page": "all", "filter": "resolved"})).is_err()); } + +#[test] +fn prompt_briefs_a_thread_for_an_agent() { + let mut s = Session::new(); + let f = frame(&mut s, 100.0, 100.0, "Button"); + let c = add(&mut s, 150.0, 160.0, "Make the padding 12", "You"); + s.execute("comment.reply", &json!({"id": c, "text": "Agreed", "author": "Sam"})).unwrap(); + let r = s.execute("comment.prompt", &json!({})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!(r["truncated"], false); + let text = r["text"].as_str().unwrap(); + assert!(text.contains("# FigCraft feedback")); + assert!(text.contains("Make the padding 12")); + assert!(text.contains("Button")); + assert!(text.contains(&format!("node {f}"))); + assert!(text.contains("Replies:") && text.contains("Sam: Agreed")); + assert!(text.contains(&format!("figcraft://comment/{c}"))); + assert!(text.contains("comment.reply") && text.contains("comment.resolve")); + let list = r["comments"].as_array().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["id"], c); + assert_eq!(list[0]["anchor"].as_u64(), Some(f.0)); +} + +#[test] +fn prompt_filters_and_explicit_ids() { + let mut s = Session::new(); + let a = add(&mut s, 0.0, 0.0, "First", "You"); + let b = add(&mut s, 10.0, 10.0, "Second", "You"); + s.execute("comment.resolve", &json!({"id": b})).unwrap(); + let open = s.execute("comment.prompt", &json!({})).unwrap(); + assert_eq!(open["count"], 1); + assert_eq!(open["comments"][0]["id"], a); + let all = s.execute("comment.prompt", &json!({"includeResolved": true})).unwrap(); + assert_eq!(all["count"], 2); + let resolved = s.execute("comment.prompt", &json!({"filter": "resolved"})).unwrap(); + assert_eq!(resolved["count"], 1); + assert_eq!(resolved["comments"][0]["id"], b); + let one = s.execute("comment.prompt", &json!({"ids": [b]})).unwrap(); + assert_eq!(one["count"], 1); + assert!(one["text"].as_str().unwrap().contains("Second")); + assert!(s.execute("comment.prompt", &json!({"ids": []})).is_err()); + assert!(s.execute("comment.prompt", &json!({"ids": [999]})).is_err()); + assert!(s.execute("comment.prompt", &json!({"filter": "weird"})).is_err()); + assert!(s.execute("comment.prompt", &json!({"sort": "weird"})).is_err()); + assert!(s.execute("comment.prompt", &json!({"page": 999})).is_err()); +} + +#[test] +fn prompt_reads_every_page_by_default() { + let mut s = Session::new(); + let p1 = s.page().unwrap(); + let a = add(&mut s, 10.0, 10.0, "One", "You"); + let p2 = id_of(&s.execute("page.add", &json!({"name": "Two"})).unwrap()["id"]).unwrap(); + s.execute("page.select", &json!({"id": p2})).unwrap(); + let b = add(&mut s, 10.0, 10.0, "Two", "You"); + let all = s.execute("comment.prompt", &json!({})).unwrap(); + assert_eq!(all["count"], 2); + let one = s.execute("comment.prompt", &json!({"page": p1.0})).unwrap(); + assert_eq!(one["count"], 1); + assert_eq!(one["comments"][0]["id"], a); + let two = s.execute("comment.prompt", &json!({"page": p2.0})).unwrap(); + assert_eq!(two["comments"][0]["id"], b); +} + +#[test] +fn prompt_is_bounded_and_empty_is_safe() { + let mut s = Session::new(); + let empty = s.execute("comment.prompt", &json!({})).unwrap(); + assert_eq!(empty["count"], 0); + assert!(empty["text"].as_str().unwrap().contains("No comments match")); + for i in 0..201 { + add(&mut s, f64::from(i), 0.0, "Fix", "You"); + } + let r = s.execute("comment.prompt", &json!({})).unwrap(); + assert_eq!(r["count"], 201); + assert_eq!(r["truncated"], true); + assert!(r["text"].as_str().unwrap().contains("Truncated: 1 more")); + let briefs = r["comments"].as_array().unwrap(); + assert_eq!(briefs.len(), 200, "bounded metadata, not every thread"); + assert!(briefs[0]["replies"].as_u64().is_some(), "a reply count, not the replies"); +} + +#[test] +fn prompt_stops_inside_one_huge_thread_without_splitting_a_character() { + let mut s = Session::new(); + let id = add(&mut s, 10.0, 20.0, "Start", "You"); + let body = "é".repeat(4096); + for _ in 0..40 { + s.execute("comment.reply", &json!({"id": id, "text": body, "author": "Sam"})).unwrap(); + } + let r = s.execute("comment.prompt", &json!({})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!(r["truncated"], true); + let text = r["text"].as_str().unwrap(); + assert!(text.len() <= 256 * 1024, "{} bytes", text.len()); + assert!(text.contains("Truncated") && text.contains("last thread is too long"), "{text}"); + let briefs = r["comments"].as_array().unwrap(); + assert_eq!(briefs.len(), 1); + assert!(briefs[0]["replies"].as_u64() == Some(40), "a reply count, not the replies"); + assert!(briefs[0]["preview"].as_str().is_some_and(|p| p.chars().count() <= 161)); + assert!(serde_json::to_string(&briefs).unwrap().len() < 2_000, "the metadata stays bounded"); +} + +#[test] +fn prompt_truncates_a_huge_document_name_even_with_no_comments() { + let mut s = Session::new(); + s.doc_mut().unwrap().name = "x".repeat(300_000); + let empty = s.execute("comment.prompt", &json!({})).unwrap(); + assert_eq!(empty["count"], 0); + assert_eq!(empty["truncated"], true); + let text = empty["text"].as_str().unwrap(); + assert!(text.len() <= 256 * 1024, "{} bytes", text.len()); + assert!(text.contains("Document name or path is truncated"), "{text}"); + + add(&mut s, 10.0, 20.0, "Fix", "You"); + let r = s.execute("comment.prompt", &json!({})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!(r["truncated"], true); + let text = r["text"].as_str().unwrap(); + assert!(text.len() <= 256 * 1024, "{} bytes", text.len()); + assert!(text.contains("Document name or path is truncated"), "{text}"); +} + +#[test] +fn prompt_keeps_control_characters_out_of_single_line_fields() { + let mut s = Session::new(); + s.doc_mut().unwrap().name = "Doc\nName".into(); + let page = s.page().unwrap(); + s.doc_mut().unwrap().node_mut(page).unwrap().name = "Pa\nge".into(); + frame(&mut s, 100.0, 100.0, "Fra\nme"); + let id = add(&mut s, 150.0, 160.0, "Fix", "Bad\nAuthor"); + s.execute("comment.reply", &json!({"id": id, "text": "Reply", "author": "Re\nplier"})).unwrap(); + let r = s.execute("comment.prompt", &json!({})).unwrap(); + let text = r["text"].as_str().unwrap(); + for (bad, good) in + [("Doc\nName", "Doc Name"), ("Pa\nge", "Pa ge"), ("Fra\nme", "Fra me"), ("Bad\nAuthor", "Bad Author"), ("Re\nplier", "Re plier")] + { + assert!(!text.contains(bad), "{bad:?} in {text}"); + assert!(text.contains(good), "{good:?} in {text}"); + } + assert!(text.contains("untrusted feedback data"), "{text}"); +} + +#[test] +fn prompt_normalizes_carriage_returns_in_messages() { + let mut s = Session::new(); + let id = add(&mut s, 10.0, 20.0, "First\r\nSecond\rThird", "You"); + s.execute("comment.reply", &json!({"id": id, "text": "A\rB\r\nC", "author": "Sam"})).unwrap(); + let r = s.execute("comment.prompt", &json!({})).unwrap(); + let text = r["text"].as_str().unwrap(); + assert!(!text.contains('\r'), "{text:?}"); + let quoted: Vec<&str> = text.lines().filter(|l| l.starts_with("> ")).collect(); + assert!(quoted.contains(&"> First") && quoted.contains(&"> Second") && quoted.contains(&"> Third"), "{text}"); + assert!(text.contains("- Sam: A B"), "{text}"); +} diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 4cd3d7760..2c3706f8f 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -43,6 +43,18 @@ fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) { (set, variants[0], variants[1]) } +#[test] +fn strict_mode_reserved_words_are_not_parameter_names() { + let mut s = Session::new(); + let (c, title, _) = card(&mut s); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Protected", "type": "text", "layer": title})); + let react = code(&mut s, c, "react"); + assert!(react.contains("protectedValue"), "React renames the property:\n{react}"); + for bad in [" protected?", " protected =", "{protected}"] { + assert!(!react.contains(bad), "reserved word used as a binding ({bad:?}):\n{react}"); + } +} + #[test] fn components_take_their_properties_as_parameters() { let mut s = Session::new(); @@ -71,6 +83,17 @@ fn components_take_their_properties_as_parameters() { for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] { assert!(kt.contains(want), "Compose has {want:?}:\n{kt}"); } + let ark = code(&mut s, c, "arkui"); + for want in [ + "@Component\nexport struct Card {", + " @Prop showDot: boolean = true", + " @Prop title: string = \"Hello\"", + " build() {", + " if (this.showDot) {", + "Text(this.title)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } let leptos = code(&mut s, c, "leptos"); for want in [ "use leptos::prelude::*;\n\n#[component]\npub fn Card(\n", @@ -83,6 +106,17 @@ fn components_take_their_properties_as_parameters() { ] { assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}"); } + let dart = code(&mut s, c, "flutter"); + for want in [ + "import 'package:flutter/material.dart';\n\nclass Card extends StatelessWidget {\n", + " const Card({\n super.key,\n this.showDot = true,\n this.title = \"Hello\",\n });\n", + " final bool showDot;\n final String title;\n", + " Widget build(BuildContext context) {\n return Container(", + "if (showDot)\n", + "Text(\n title,", + ] { + assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}"); + } // The command describes the properties. let v = component_code(&mut s, c, "react"); assert_eq!(v["name"], "Card"); @@ -117,13 +151,28 @@ fn instances_are_calls_passing_the_values_they_set() { assert!(react.contains("export function Card({")); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n"); + assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Buy\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); // Text that needs escaping is an expression in JSX and escaped per language. ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); + // A quoted title becomes a binding whose attribute value is escaped: the expression itself + // keeps its JS quotes, and the attribute's own quotes are entities. + assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n"); + let vue = code(&mut s, i, "vue"); + assert!(vue.starts_with("\n"), "{vue}"); + // A plain attribute keeps HTML entities intact: a literal ampersand is written as `&`, + // and a literal `&` as `&amp;`. + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}})); + assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n"); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}})); + assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n"); } #[test] @@ -139,6 +188,8 @@ fn nested_instances_in_a_layer_tree_are_calls() { assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}"); assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")")); assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")")); + let dart = code(&mut s, screen, "flutter"); + assert!(dart.contains("Card(title: \"Inbox\")") && !dart.contains("Hello"), "{dart}"); let leptos = code(&mut s, screen, "leptos"); assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains(""), "{leptos}"); assert!(!leptos.contains("Hello"), "{leptos}"); @@ -173,6 +224,9 @@ fn variant_sets_share_one_definition() { let kt = code(&mut s, set, "compose"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); + let ark = code(&mut s, set, "arkui"); + assert!(ark.contains("export enum ButtonState { Default, Hover }"), "{ark}"); + assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}"); let leptos = code(&mut s, set, "leptos"); for want in [ "#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}", @@ -189,9 +243,23 @@ fn variant_sets_share_one_definition() { assert_eq!(component_code(&mut s, i, "react")["usage"], "