diff --git a/.github/workflows/ai-core.yml b/.github/workflows/ai-core.yml index 4733eaee..5f2204f7 100644 --- a/.github/workflows/ai-core.yml +++ b/.github/workflows/ai-core.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy targets: wasm32-unknown-unknown diff --git a/.github/workflows/atomic.yml b/.github/workflows/atomic.yml index 25423780..d2112768 100644 --- a/.github/workflows/atomic.yml +++ b/.github/workflows/atomic.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - name: Install ACL tools diff --git a/.github/workflows/docpath.yml b/.github/workflows/docpath.yml index 557f00f5..3aaaa97e 100644 --- a/.github/workflows/docpath.yml +++ b/.github/workflows/docpath.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - run: cargo fmt --check diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index d7e6f5b5..a973e37f 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - run: cargo fmt --check diff --git a/.github/workflows/media.yml b/.github/workflows/media.yml index 088f255b..a9fa3213 100644 --- a/.github/workflows/media.yml +++ b/.github/workflows/media.yml @@ -22,11 +22,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-media-lint - run: cargo fmt -p craft-media --check @@ -42,8 +44,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-media-test - name: Build tests while installing ffmpeg diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index e0d9fcf1..c2f8c3f6 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -27,10 +27,10 @@ jobs: RUSTFLAGS: --cfg craft_pdf_unicode_fixtures steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-pdf - uses: actions/setup-python@v5 @@ -51,8 +51,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-pdf-backends - run: bash scripts/test-pdf-backends.sh @@ -64,10 +64,10 @@ jobs: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-pdf-wasm - uses: actions/setup-python@v5 diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index ea0696e3..270ac75e 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -18,7 +18,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - run: cargo fmt --check diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 00000000..e78fe1ea --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,21 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index 79845a44..3db33478 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -32,10 +32,10 @@ jobs: sudo apt-get update sudo apt-get install -y mesa-vulkan-drivers libvulkan1 echo 'WGPU_BACKEND=vulkan' >> "$GITHUB_ENV" - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-ui - run: cargo fmt --check @@ -49,10 +49,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: craft-ui-wasm - run: cargo check -p craft-ui --all-features --locked --target wasm32-unknown-unknown diff --git a/.github/workflows/window.yml b/.github/workflows/window.yml new file mode 100644 index 00000000..80aa86b6 --- /dev/null +++ b/.github/workflows/window.yml @@ -0,0 +1,36 @@ +name: Window decorations + +on: + workflow_dispatch: + push: + branches: [main] + paths: ['crates/window/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/window.yml'] + pull_request: + paths: ['crates/window/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/window.yml'] + +permissions: + contents: read + +jobs: + window: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + # The token isn't needed after checkout; cargo runs build scripts and proc macros from + # dependencies, so don't leave it in .git/config for that third-party code to read. + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + - run: cargo fmt --check + # Default (no egui) and the egui feature both build and lint cleanly. + - run: cargo clippy -p craft-window --all-targets --locked -- -D warnings + - run: cargo clippy -p craft-window --all-targets --locked --features egui -- -D warnings + - run: cargo test -p craft-window --locked -- --nocapture + - run: cargo test -p craft-window --locked --features egui -- --nocapture diff --git a/.gitignore b/.gitignore index 9108c660..c725b3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ plans/ .DS_Store /.claude/ .idea/ +/vendor/*/target diff --git a/AGENTS.md b/AGENTS.md index 03e7ff50..e5adca3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,5 +10,6 @@ Shared Rust libraries for the Craft Apps. The shared standards live in - **New crates** go in `crates/`, named `craft-`, inherit the workspace package fields and lints, and are listed in `[workspace.dependencies]`. - **No app code.** A crate here must be useful to more than one app and must not depend on any app. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Before merging:** `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`. diff --git a/Cargo.lock b/Cargo.lock index ebc4055f..2b2ebaeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -94,26 +94,6 @@ dependencies = [ "libc", ] -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "image", - "log", - "objc2 0.6.5", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "x11rb", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -135,6 +115,126 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -273,6 +373,19 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.8.1" @@ -370,30 +483,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" -dependencies = [ - "bitflags 2.13.2", - "polling", - "rustix 1.1.5", - "tracing", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.5", - "rustix 1.1.5", - "wayland-backend", - "wayland-client", -] - [[package]] name = "cast" version = "0.3.0" @@ -449,15 +538,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - [[package]] name = "cmake" version = "0.1.58" @@ -629,6 +709,7 @@ dependencies = [ name = "craft-ai-core" version = "0.1.0" dependencies = [ + "chrono", "serde", "serde_json", ] @@ -657,6 +738,7 @@ name = "craft-i18n" version = "0.1.0" dependencies = [ "intl_pluralrules", + "log", "sys-locale", "unic-langid", "unicode-bidi", @@ -687,6 +769,23 @@ dependencies = [ "wasm-bindgen-test", ] +[[package]] +name = "craft-platform" +version = "0.1.0" +dependencies = [ + "async-channel", + "async-io", + "async-process", + "egui", + "futures-lite", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "zbus", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -733,6 +832,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-window" +version = "0.1.0" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -944,9 +1051,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ecolor" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" dependencies = [ "bytemuck", "emath", @@ -954,9 +1061,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" dependencies = [ "ahash", "bytemuck", @@ -975,6 +1082,7 @@ dependencies = [ "pollster", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -986,9 +1094,9 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" dependencies = [ "accesskit", "ahash", @@ -1006,9 +1114,9 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" dependencies = [ "ahash", "bytemuck", @@ -1026,29 +1134,29 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" dependencies = [ - "arboard", - "bytemuck", + "block2 0.6.2", "egui", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", - "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +checksum = "def286cf47194f3fe45b8e37c503ada7a20a64b59a903b4bc704baec943b1827" dependencies = [ "ahash", "egui", @@ -1061,9 +1169,9 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" dependencies = [ "eframe", "egui", @@ -1085,9 +1193,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" dependencies = [ "bytemuck", ] @@ -1107,19 +1215,46 @@ dependencies = [ ] [[package]] -name = "enum-map" -version = "2.7.3" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 3.0.6", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", @@ -1128,9 +1263,9 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" dependencies = [ "ahash", "bytemuck", @@ -1147,15 +1282,16 @@ dependencies = [ "skrifa 0.44.0", "smallvec", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" [[package]] name = "equivalent" @@ -1173,12 +1309,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" - [[package]] name = "euclid" version = "0.22.14" @@ -1188,18 +1318,32 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - [[package]] name = "fdeflate" version = "0.3.7" @@ -1373,6 +1517,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -1410,16 +1567,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.5", - "windows-link", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1487,9 +1634,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", @@ -1498,7 +1645,7 @@ dependencies = [ "peniko", "skrifa 0.44.0", "smallvec", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -1961,7 +2108,6 @@ dependencies = [ "moxcms", "num-traits", "png 0.18.1", - "tiff", ] [[package]] @@ -2134,9 +2280,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", "accesskit_consumer", @@ -2284,12 +2430,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "memmap2" -version = "0.9.11" +name = "memoffset" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ - "libc", + "autocfg", ] [[package]] @@ -2536,9 +2682,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.5", "objc2-core-foundation", - "objc2-core-graphics", "objc2-foundation 0.3.2", ] @@ -2595,7 +2741,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.2", "dispatch2", "objc2 0.6.5", "objc2-core-foundation", @@ -2841,6 +2986,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2998,6 +3159,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3702,12 +3874,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -3749,6 +3915,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -3803,6 +3975,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "1.1.2" @@ -3852,6 +4035,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -4109,44 +4302,6 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.2", - "calloop 0.14.5", - "calloop-wayland-source", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.5", - "thiserror 2.0.21", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -4424,20 +4579,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error 2.0.1", - "weezl", - "zune-jpeg", -] - [[package]] name = "tiny-skia" version = "0.11.4" @@ -4654,7 +4795,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4710,6 +4850,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -4854,6 +5005,11 @@ name = "uuid" version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] [[package]] name = "vello_common" @@ -4891,9 +5047,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", @@ -4919,14 +5075,14 @@ 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 0.2.0", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -5073,7 +5229,6 @@ dependencies = [ "cc", "downcast-rs", "rustix 1.1.5", - "scoped-tls", "smallvec", "wayland-sys", ] @@ -5090,79 +5245,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.13.2", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" -dependencies = [ - "rustix 1.1.5", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-scanner" version = "0.31.11" @@ -5531,7 +5613,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5540,16 +5622,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5567,31 +5640,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5609,96 +5665,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -5709,7 +5717,7 @@ dependencies = [ "atomic-waker", "bitflags 2.13.2", "block2 0.5.1", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -5794,23 +5802,6 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "gethostname", - "rustix 1.1.5", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - [[package]] name = "x509-cert" version = "0.2.5" @@ -5846,12 +5837,6 @@ dependencies = [ "rustix 1.1.5", ] -[[package]] -name = "xcursor" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" - [[package]] name = "xkbcommon-dl" version = "0.4.2" @@ -5918,6 +5903,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.62" @@ -6039,3 +6094,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index ca53f61a..45ff1948 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] # internal: add each crate here as `craft- = { path = "crates/" }` +craft-platform = { path = "crates/platform" } craft-ai-core = { path = "crates/ai-core" } craft-pdf = { path = "crates/pdf", version = "=0.1.0" } krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] } @@ -29,11 +30,14 @@ craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } craft-ui = { path = "crates/ui" } -egui = { version = "0.36", default-features = false } +craft-window = { path = "crates/window" } +egui = { version = "0.37", default-features = false } rayon = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" proptest = "1" +# 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"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/NOTICE b/NOTICE index 9467671f..38a65fcf 100644 --- a/NOTICE +++ b/NOTICE @@ -106,3 +106,20 @@ Compact ICC Profiles by Clinton Ingram (CC0-1.0), and dependency CMaps (BSD-3-Cl Their licences and per-file hashes are retained in vendor/hayro-interpret/assets, licenses/pdf-backend and ATTRIBUTION.md. These are existing reader dependency assets; applications retain their own document-font selection and licensing policy. + +Platform lifecycle source provenance + +The optional crates/platform services adapt the preserved local platform candidate +9b135779a1e7136438a217c18afe1693ea23f1ab (dialog outcomes and browser resource ownership), +and current application implementations: +- PdfCraft crates/platform/src/desktop_theme.rs, a8cd797a917b1007544575c7280c9a0ce6a17e8f. +- CADCraft apps/cadcraft/src/desktop_theme.rs, 0cc66ad3066e71f2c3d4cacb7de905e79ba1c95b. +- ArtCraft Launcher apps/artcraft-launcher/src/system_theme.rs, f1676dfb41c226b5be490721f486ee6427419d80. +- Earlier PhotoCraft, FilmCraft and EffectCraft appearance services at revisions + 0c72d95425dece90ef9a1cceb49e3315c96e22d5, 794a25fc9ed2e3f6fab51a254625bc9b99a88015 + and 1b4d47f34f2b1c1b4a89f4b7348889def8fd648b respectively. +Sources: https://github.com/storytold/pdfcraft, https://github.com/storytold/cadcraft, +https://github.com/storytold/craft-launcher, https://github.com/storytold/photocraft, +https://github.com/storytold/filmcraft, https://github.com/storytold/effectcraft. +Copyright (c) 2026 ArtCraft Team and the respective application and craft-libs contributors. +All adapted source is licensed MIT OR Apache-2.0. diff --git a/README.md b/README.md index 3289aba6..2dc92f8a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ Craft App Libraries =================== +Scanned by SonarCloud + This repo contains libraries used by the various Craft Apps. It's a Rust monorepo workspace, and the libraries can be included by any Craft Apps that need them. In the future this may grow to include non-Rust library code. @@ -23,6 +25,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Vec { let mut kinds: Vec = self.items.iter().map(|i| i.kind).collect(); + if !self.param_names.is_empty() { + kinds.push(PayloadKind::Text); + } kinds.sort(); kinds.dedup(); kinds @@ -242,6 +246,17 @@ mod tests { assert!(ledger.needs_consent(&d, 0)); } + #[test] + fn parameters_alone_still_need_consent() { + let only_params = Request::new(CapabilityId::parse("image.inpaint").unwrap()).with_param("negative", "a private note".into()); + let d = disclosure_for(&only_params); + assert_eq!(d.kinds(), vec![PayloadKind::Text]); + let mut ledger = ConsentLedger::default(); + assert!(ledger.needs_consent(&d, 0)); + ledger.grant(&d); + assert!(!ledger.needs_consent(&d, 0)); + } + fn upscale_req() -> Request { Request::new(CapabilityId::parse("image.upscale").unwrap()).with_input(InputRole::Source, Payload::inline("image/png", vec![0; 10]).unwrap()) } diff --git a/crates/ai-core/src/history/report.rs b/crates/ai-core/src/history/report.rs index d13f391b..28c69317 100644 --- a/crates/ai-core/src/history/report.rs +++ b/crates/ai-core/src/history/report.rs @@ -22,17 +22,18 @@ pub struct Day { } impl Day { - /// Days since 1970-01-01 (negative before it). + /// Days since 1970-01-01 (negative before it); a day that doesn't exist (2025-02-30) reads as 0. pub fn to_days(self) -> i64 { - let (y, m, d) = (i64::from(self.year), i64::from(self.month), i64::from(self.day)); - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y.rem_euclid(400); - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 + use chrono::Datelike; + // chrono covers years up to about ±262143, but `Day` holds any `i32` year. The Gregorian + // calendar repeats every 400 years, so chrono handles the year within its cycle and + // whole cycles are added back. + let cycles = i64::from(self.year.div_euclid(400)); + chrono::NaiveDate::from_ymd_opt(self.year.rem_euclid(400), u32::from(self.month), u32::from(self.day)) + .map_or(0, |d| i64::from(d.num_days_from_ce()) - UNIX_EPOCH_DAYS_FROM_CE + cycles * DAYS_PER_400_YEARS) } + /// The calendar day at `days` since 1970-01-01; a day whose year doesn't fit `i32` reads as 0000-01-01. pub fn from_days(days: i64) -> Day { civil_from_days(days) } @@ -61,18 +62,24 @@ impl fmt::Display for Month { } } -/// Days since 1970-01-01 to a calendar day (proleptic Gregorian). +/// `NaiveDate::num_days_from_ce` of 1970-01-01, so the count can be rebased onto the Unix epoch. +const UNIX_EPOCH_DAYS_FROM_CE: i64 = 719_163; + +/// Days in one 400-year Gregorian cycle (the calendar repeats after it). +const DAYS_PER_400_YEARS: i64 = 146_097; + +/// Days since 1970-01-01 to a calendar day (proleptic Gregorian). chrono converts the day +/// within its 400-year cycle and whole cycles are added back as years, so every `i64` count +/// works; a year that doesn't fit `i32` reads as 0000-01-01. fn civil_from_days(days: i64) -> Day { - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - Day { year: i32::try_from(y).unwrap_or(0), month: u8::try_from(m).unwrap_or(1), day: u8::try_from(d).unwrap_or(1) } + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(DAYS_PER_400_YEARS), days.rem_euclid(DAYS_PER_400_YEARS)); + let date = i32::try_from(rest + UNIX_EPOCH_DAYS_FROM_CE).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let year = date.and_then(|d| i32::try_from(i64::from(d.year()) + cycles * 400).ok()); + match (date, year) { + (Some(d), Some(year)) => Day { year, month: u8::try_from(d.month()).unwrap_or(1), day: u8::try_from(d.day()).unwrap_or(1) }, + _ => Day { year: 0, month: 1, day: 1 }, + } } /// The calendar day of a Unix-millisecond time, at a fixed offset from UTC in minutes (east is diff --git a/crates/ai-core/src/history/report/tests.rs b/crates/ai-core/src/history/report/tests.rs index 919bad51..42652376 100644 --- a/crates/ai-core/src/history/report/tests.rs +++ b/crates/ai-core/src/history/report/tests.rs @@ -213,6 +213,22 @@ fn days_convert_to_and_from_the_epoch_count_both_ways() { assert_eq!(day_of(0, 0).to_days(), 0); assert_eq!(day_of(DAY_MS_FOR_TESTS, 0).to_days(), 1); assert_eq!(day_of(0, -60).to_days(), -1); + // A day count past what chrono can represent reads as the 0000-01-01 fallback, not a stray + // valid date: `i64::MAX` days would overflow the CE `i32` (which once became 0000-12-31). + let fallback = Day { year: 0, month: 1, day: 1 }; + assert_eq!(Day::from_days(i64::MAX), fallback); + assert_eq!(Day::from_days(i64::MIN), fallback); + // Valid `i32` years past chrono's ±262143 still convert, both ways, as before chrono. + for (day, days) in [ + (Day { year: 262_144, month: 1, day: 1 }, 95_026_602), + (Day { year: -262_145, month: 3, day: 1 }, -96465964), + (Day { year: i32::MAX, month: 12, day: 31 }, 784351576776), + (Day { year: i32::MIN, month: 1, day: 1 }, -784353015833), + ] { + assert_eq!(day.to_days(), days, "{day}"); + assert_eq!(Day::from_days(days), day, "{days}"); + } + assert_eq!(Day { year: 2025, month: 2, day: 30 }.to_days(), 0, "a day that doesn't exist"); } const DAY_MS_FOR_TESTS: u64 = 86_400_000; diff --git a/crates/ai-core/src/secret.rs b/crates/ai-core/src/secret.rs index b6bb9c94..0a40b834 100644 --- a/crates/ai-core/src/secret.rs +++ b/crates/ai-core/src/secret.rs @@ -40,13 +40,32 @@ impl fmt::Display for SecretString { } /// Replaces every occurrence of each non-empty secret in `text` with `[redacted]`. +/// +/// Matches are found in `text` itself, never in the markers already written, so the output is at +/// most a fixed multiple of the input whatever the secrets are. Overlapping matches of different +/// secrets become one marker. pub fn redact(text: &str, secrets: &[&SecretString]) -> String { - let mut out = text.to_owned(); - for secret in secrets { - if !secret.is_empty() { - out = out.replace(secret.expose(), "[redacted]"); + const MARKER: &str = "[redacted]"; + let mut values: Vec<&str> = secrets.iter().map(|s| s.expose()).filter(|v| !v.is_empty()).collect(); + values.sort_unstable(); + values.dedup(); + let mut spans: Vec<(usize, usize)> = values.iter().flat_map(|v| text.match_indices(v).map(|(at, m)| (at, at + m.len()))).collect(); + spans.sort_unstable(); + let mut out = String::with_capacity(text.len()); + let mut done = 0; + let mut i = 0; + while i < spans.len() { + let (start, mut end) = spans[i]; + i += 1; + while i < spans.len() && spans[i].0 < end { + end = end.max(spans[i].1); + i += 1; } + out.push_str(&text[done..start]); + out.push_str(MARKER); + done = end; } + out.push_str(&text[done..]); out } @@ -69,6 +88,24 @@ mod tests { assert_eq!(out, "bad key [redacted] ([redacted])"); } + #[test] + fn redact_never_searches_its_own_markers() { + // Each secret matches the marker the one before it wrote; replacing in turn doubled the + // text for every secret. + let r = SecretString::new("r"); + let marker = SecretString::new("[redacted]"); + let secrets: Vec<&SecretString> = std::iter::once(&r).chain(std::iter::repeat_n(&marker, 64)).collect(); + assert_eq!(redact("rrrr", &secrets), "[redacted][redacted][redacted][redacted]"); + } + + #[test] + fn redact_merges_overlapping_secrets() { + let a = SecretString::new("abc"); + let b = SecretString::new("bcd"); + assert_eq!(redact("xabcdx", &[&a, &b]), "x[redacted]x"); + assert_eq!(redact("abcabc", &[&a]), "[redacted][redacted]"); + } + #[test] fn redact_ignores_empty_secret() { let s = SecretString::new(""); diff --git a/crates/ai-core/src/spec.rs b/crates/ai-core/src/spec.rs index 424ac905..0712e266 100644 --- a/crates/ai-core/src/spec.rs +++ b/crates/ai-core/src/spec.rs @@ -37,8 +37,11 @@ pub const SPEC_VERSION: u32 = 1; pub const MAX_SPEC_BYTES: usize = 32 * 1024; /// Most models one spec may offer. pub const MAX_MODELS: usize = 16; -/// Largest rendered or stored template (a body, serialized). +/// Largest stored template (a body, serialized). pub const MAX_TEMPLATE_BYTES: usize = 4 * 1024; +/// Most text one rendered template may hold, summed over its strings with every substitution made. +/// A slot may repeat, so this, not [`MAX_TEMPLATE_BYTES`], bounds what a prompt can grow to. +pub const MAX_RENDERED_BYTES: usize = 4 * 1024 * 1024; /// Deepest JSON pointer. pub const MAX_POINTER_DEPTH: usize = 8; /// Most entries in a status table. diff --git a/crates/ai-core/src/spec/template.rs b/crates/ai-core/src/spec/template.rs index d276ab61..bc1d79b4 100644 --- a/crates/ai-core/src/spec/template.rs +++ b/crates/ai-core/src/spec/template.rs @@ -114,23 +114,40 @@ pub fn value_slots(template: &Value) -> Result, String> { Ok(out) } +/// Renders one string. Fails past [`MAX_RENDERED_BYTES`](crate::spec::MAX_RENDERED_BYTES). pub fn render_text(text: &str, values: &Values<'_>) -> Result { + let mut left = crate::spec::MAX_RENDERED_BYTES; + render_text_within(text, values, &mut left) +} + +/// Renders `text`, taking its length from `left`; a template that repeats a slot cannot outgrow it. +fn render_text_within(text: &str, values: &Values<'_>, left: &mut usize) -> Result { let mut out = String::new(); for piece in pieces(text)? { - match piece { - Piece::Text(t) => out.push_str(&t), - Piece::Slot(slot, name) => out.push_str(values.get(slot).ok_or_else(|| format!("{{{name}}} is not available in this call"))?), - } + let add = match &piece { + Piece::Text(t) => t.as_str(), + Piece::Slot(slot, name) => values.get(*slot).ok_or_else(|| format!("{{{name}}} is not available in this call"))?, + }; + *left = left.checked_sub(add.len()).ok_or_else(|| format!("a rendered template is at most {} bytes", crate::spec::MAX_RENDERED_BYTES))?; + out.push_str(add); } Ok(out) } -/// Renders every string value of `template`. Keys are never substituted. +/// Renders every string value of `template`. Keys are never substituted. All strings together +/// share one [`MAX_RENDERED_BYTES`](crate::spec::MAX_RENDERED_BYTES) budget. pub fn render_value(template: &Value, values: &Values<'_>) -> Result { + let mut left = crate::spec::MAX_RENDERED_BYTES; + render_value_within(template, values, &mut left) +} + +fn render_value_within(template: &Value, values: &Values<'_>, left: &mut usize) -> Result { Ok(match template { - Value::String(s) => Value::String(render_text(s, values)?), - Value::Array(items) => Value::Array(items.iter().map(|i| render_value(i, values)).collect::>()?), - Value::Object(map) => Value::Object(map.iter().map(|(k, v)| Ok((k.clone(), render_value(v, values)?))).collect::>()?), + Value::String(s) => Value::String(render_text_within(s, values, left)?), + Value::Array(items) => Value::Array(items.iter().map(|i| render_value_within(i, values, left)).collect::>()?), + Value::Object(map) => { + Value::Object(map.iter().map(|(k, v)| Ok((k.clone(), render_value_within(v, values, left)?))).collect::>()?) + } other => other.clone(), }) } diff --git a/crates/ai-core/tests/spec_template.rs b/crates/ai-core/tests/spec_template.rs index f5689f67..8c7deeed 100644 --- a/crates/ai-core/tests/spec_template.rs +++ b/crates/ai-core/tests/spec_template.rs @@ -65,3 +65,15 @@ fn a_slot_with_no_value_is_an_error_not_an_empty_string() { let why = render_text("/jobs/{job_id}", &v).unwrap_err(); assert!(why.contains("{job_id} is not available"), "{why}"); } + +#[test] +fn repeated_slots_share_one_rendered_budget() { + let prompt = "x".repeat(64 * 1024); + let v = Values { prompt: Some(&prompt), ..values() }; + let slots = vec![json!("{prompt}"); 16]; + assert!(render_value(&json!({ "a": slots }), &v).is_ok()); + let slots = vec![json!("{prompt}{prompt}{prompt}{prompt}"); 32]; + let why = render_value(&json!({ "a": slots }), &v).unwrap_err(); + assert!(why.contains("at most"), "{why}"); + assert!(render_text(&"{prompt}".repeat(128), &v).is_err()); +} diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs index e551638d..dd0315c2 100644 --- a/crates/cms/src/clut.rs +++ b/crates/cms/src/clut.rs @@ -223,15 +223,22 @@ impl Clut { } fn multilinear(&self, input: &[f32], out: &mut [f32]) { - let n = self.inputs; + // Only dimensions that sit between two grid points spread over corners. A singleton + // dimension, or an input on a node, takes its lower corner with weight 1, so a table of + // many such dimensions costs no more than the dimensions that actually interpolate. let mut base = 0usize; let mut r = [0.0f32; MAX_CHANNELS]; let mut s = [0usize; MAX_CHANNELS]; - for d in 0..n { - let (b, rr, ss) = self.locate(d, input[d]); + let mut n = 0; + for (d, &v) in input.iter().enumerate().take(self.inputs) { + let (b, rr, ss) = self.locate(d, v); base += b; - r[d] = rr; - s[d] = ss; + // `!= 0.0` keeps a NaN fraction, so a NaN input still gives a NaN result. + if ss > 0 && rr != 0.0 { + r[n] = rr; + s[n] = ss; + n += 1; + } } let o = self.outputs; out[..o].iter_mut().for_each(|v| *v = 0.0); @@ -309,4 +316,38 @@ mod tests { c.eval(&[0.5, 0.25], &mut o).unwrap(); assert!((o[0] - 0.75).abs() < 1e-6); } + + #[test] + fn a_nan_input_still_gives_nan() { + let c = Clut::sample(vec![2; 5], 1, |p, o| o[0] = p.iter().sum()).unwrap(); + let mut o = [0.0]; + c.eval_multilinear(&[0.5, f32::NAN, 0.5, 0.5, 0.5], &mut o).unwrap(); + assert!(o[0].is_nan(), "{o:?}"); + } + + #[test] + fn singleton_dimensions_do_not_multiply_corners() { + // Fifteen inputs, all but two on a single grid point: the result interpolates only those + // two, and matches the same table built with two inputs. + let mut grid = vec![1usize; 15]; + grid[3] = 2; + grid[9] = 3; + let wide = Clut::sample(grid, 2, |p, o| { + o[0] = p[3] * 0.5 + p[9] * 0.25; + o[1] = p[3] * p[9]; + }) + .unwrap(); + let narrow = Clut::sample(vec![2, 3], 2, |p, o| { + o[0] = p[0] * 0.5 + p[1] * 0.25; + o[1] = p[0] * p[1]; + }) + .unwrap(); + let mut input = [0.4f32; 15]; + input[3] = 0.3; + input[9] = 0.7; + let (mut a, mut b) = ([0.0; 2], [0.0; 2]); + wide.eval_multilinear(&input, &mut a).unwrap(); + narrow.eval_multilinear(&[0.3, 0.7], &mut b).unwrap(); + assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "{a:?} {b:?}"); + } } diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index f50f2d66..2ea74a91 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -85,6 +85,20 @@ impl Curve { } } + /// [`eval_inverse64`](Self::eval_inverse64) for many values. A table's flat ends are found + /// once here instead of on every call, so building an output table from a long, flat table + /// costs one scan of it, not one per entry. + pub fn inverse64_many(&self) -> impl Fn(f64) -> f64 + '_ { + let edges = match self { + Curve::Table(t) if t.len() >= 2 => Some((edge_index(t, true), edge_index(t, false))), + _ => None, + }; + move |y| match (self, edges) { + (Curve::Table(t), Some((first, last))) => table_inverse_with(t, y as f32, |start| if start { first } else { last }) as f64, + _ => self.eval_inverse64(y), + } + } + #[inline] pub fn eval_inverse(&self, y: f32) -> f32 { match self { @@ -240,6 +254,11 @@ fn table(t: &[f32], x: f32) -> f32 { /// Exact inverse of a piecewise-linear table (binary search), for increasing or decreasing tables. fn table_inverse(t: &[f32], y: f32) -> f32 { + table_inverse_with(t, y, |start| edge_index(t, start)) +} + +/// [`table_inverse`] with the flat-run ends from `edge` (see [`edge_index`]). +fn table_inverse_with(t: &[f32], y: f32, edge: impl Fn(bool) -> f32) -> f32 { let n = t.len(); if n < 2 { return y; @@ -248,10 +267,10 @@ fn table_inverse(t: &[f32], y: f32) -> f32 { let (lo_v, hi_v) = if increasing { (t[0], t[n - 1]) } else { (t[n - 1], t[0]) }; if y <= lo_v { // Flat start: choose the last index still at the lowest value. - return if increasing { edge_index(t, true) } else { edge_index(t, false) }; + return edge(increasing); } if y >= hi_v { - return if increasing { edge_index(t, false) } else { edge_index(t, true) }; + return edge(!increasing); } let (mut lo, mut hi) = (0usize, n - 1); while hi - lo > 1 { @@ -329,6 +348,23 @@ mod tests { assert!((dec.eval_inverse(0.25) - 0.75).abs() < 1e-6); } + #[test] + fn batch_inverse_matches_single_inverse() { + let mut flat = vec![0.25f32; 300]; + flat.extend((0..300).map(|i| 0.25 + i as f32 / 600.0)); + flat.extend([0.75f32; 300]); + for t in [flat.clone(), flat.iter().rev().copied().collect(), vec![0.5; 1000], Curve::Gamma(2.2).sample(256)] { + let c = Curve::Table(t); + let many = c.inverse64_many(); + for i in 0..=200 { + let y = i as f64 / 200.0; + assert_eq!(many(y), c.eval_inverse64(y), "{y}"); + } + } + let g = Curve::Gamma(2.2); + assert_eq!(g.inverse64_many()(0.3), g.eval_inverse64(0.3)); + } + #[test] fn hdr_and_negative_extension() { let c = srgb_trc(); diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index 1dc59e82..7a8aeb0a 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -150,10 +150,11 @@ impl Transform { let tables = cs .iter() .map(|c| { + let inverse = c.inverse64_many(); (0..OUT_TABLE) .map(|i| { let u = i as f64 / (OUT_TABLE - 1) as f64; - c.eval_inverse64(u * u * u * u).clamp(0.0, 1.0) as f32 + inverse(u * u * u * u).clamp(0.0, 1.0) as f32 }) .collect() }) diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 8283943f..99b64a7d 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true [dependencies] intl_pluralrules = "=7.0.2" unic-langid = "0.9" +log = "0.4" sys-locale = "0.3" unicode-bidi = "0.3" diff --git a/crates/i18n/README.md b/crates/i18n/README.md index dc2e3c46..97523eba 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -20,8 +20,8 @@ include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs")); // pub static CRAFT_I18N: pub static I18N: LazyLock = LazyLock::new(|| { let offered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect(); // the app's own list - let (i18n, problems) = craft_i18n::I18n::new(CRAFT_I18N, &offered); - problems.iter().for_each(|p| log::warn!("translation catalog: {p}; that string shows in English")); + let (i18n, problems) = craft_i18n::I18n::new(CRAFT_I18N, &offered); // parses nothing yet + problems.iter().for_each(|p| log::warn!("translation catalog: {p}")); i18n }); @@ -62,23 +62,29 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l `strip_isolates`. - **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice, of which the first is kept) and bad locales (`en`, `pseudo`, a code embedded twice in any case) - are skipped and returned by `I18n::new` for the app to log; craft-i18n's CI rejects them before - they ship. + are skipped; craft-i18n's CI rejects them before they ship. `I18n::new` parses nothing: it + returns the bad locales, and each locale's rows are parsed (and its bad rows logged with + `log::warn!`) on the first lookup that reaches it, including as a fallback. `I18n::check_all()` + parses every locale and returns all problems (`/: line N: …`), for a test. - **Plurals:** forms are listed in CLDR order (zero, one, two, few, many, other) for the categories whole numbers 0 to 1000 reach, from `intl_pluralrules`; a count in any other category takes the last form. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` `pt`'s. `intl_pluralrules` is pinned to the version craft-i18n's checker uses, and `plural_form_counts` holds the same counts. -- **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its - code; English is "English". -- **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and - `yi`; putting text into display order is the app's job. +- **Names:** a language's name comes from the app catalog's `@id language.name` row, else the + shared catalog's, else its code; English is "English". +- **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its + interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab), + `direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` + gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → + SC, `zh-TW`/`zh-US`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is + `direction` of the language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and - Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → - `zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script - is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` - picks `sr`. + Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → `zh-hant`, + `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never + crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`. + A region that implies a script counts as that script: `pa-PK` picks `pa-arab`, not Gurmukhi `pa`. - **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get` included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 50d731b2..0cb51e36 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -37,7 +37,7 @@ pub(crate) fn parse(text: &str, forms: usize) -> (Vec, Vec) { (rows, problems) } -fn parse_row(text: &str, forms: usize) -> Result { +pub(crate) fn parse_row(text: &str, forms: usize) -> Result { let columns: Vec<&str> = text.split('\t').collect(); let [context, source, translation] = columns.as_slice() else { return Err(format!("{} columns; expected 3", columns.len())); diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index bbffc260..d381f49c 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod scripts; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -14,6 +15,8 @@ use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; use unicode_bidi::{BidiClass, bidi_class}; +pub use scripts::{Direction, Han, Script, base_direction, direction, han, scripts}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; @@ -32,7 +35,14 @@ impl Lang { /// One locale's translations: context → source text → translation, app rows over shared ones. struct Locale { code: String, - rows: HashMap>, + /// The shared and the app's catalog text, parsed on first use. + shared: &'static str, + app: &'static str, + /// Plural forms `@plural` rows must list, for [`catalog::parse`]. + forms: usize, + parsed: OnceLock, + /// [`I18n::name`], read from the text on first use. + name: OnceLock>, /// Itself, then its parent without the region if that is embedded too (`pt-br`, `pt`; /// `zh-hant-hk`, `zh-hant`). Empty for English and the pseudo locale. chain: Vec, @@ -43,7 +53,49 @@ struct Locale { plural: Option<(PluralRules, Vec)>, } -/// The embedded catalogs, ready for lookups. Build it once, at start-up. +/// A locale's rows, app over shared, and what was wrong with the catalogs it came from. +struct Parsed { + rows: HashMap>, + problems: Vec, +} + +impl Locale { + /// The rows; the catalogs are parsed on the first call and any problems logged then. + fn parsed(&self) -> &Parsed { + self.parsed.get_or_init(|| { + let mut problems = Vec::new(); + let mut rows: HashMap> = HashMap::new(); + for (name, text) in [("common", self.shared), ("app", self.app)] { + let (entries, errors) = catalog::parse(text, self.forms); + problems.extend(errors.into_iter().map(|e| format!("{}/{name}: {e}", self.code))); + for e in entries { + rows.entry(e.context).or_default().insert(e.source, e.translation); + } + } + problems.iter().for_each(|p| log::warn!("translation catalog: {p}; row skipped")); + Parsed { rows, problems } + }) + } + + /// The `@id language.name` row of the app's catalog, else the shared one, found by scanning the + /// text so the locale needn't be parsed. + fn language_name(&self) -> Option<&str> { + self.name + .get_or_init(|| { + [self.app, self.shared].into_iter().find_map(|text| { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + text.split('\n') + .filter(|line| line.starts_with("@id\tlanguage.name\t")) + .find_map(|line| catalog::parse_row(line.strip_suffix('\r').unwrap_or(line), self.forms).ok()) + .map(|row| row.translation) + }) + }) + .as_deref() + } +} + +/// The embedded catalogs, ready for lookups. Build it once, at start-up. Each locale is parsed on +/// its first lookup, which is also when its bad rows are logged. pub struct I18n { locales: Vec, /// Marked strings for the pseudo locale by source text, leaked so lookups can hand out `&str`. @@ -56,10 +108,11 @@ pub struct I18n { } impl I18n { - /// Read `embedded`, offering the locales in `offered` (any case; the app's own language list), - /// so an app never offers a language its interface isn't ready for (right to left, say). Rows - /// and locales that can't be used are skipped and come back as problems - /// (`/: line N: …`) for the app to log. + /// Index `embedded` without parsing it, offering the locales in `offered` (any case; the app's + /// own language list), so an app never offers a language its interface isn't ready for (right + /// to left, say). Locales that can't be used are skipped and come back as problems for the app + /// to log. A locale's bad rows are logged (`log::warn!`) when it is first looked up in, and + /// [`I18n::check_all`] lists them all. pub fn new(embedded: &'static Embedded, offered: &[&str]) -> (I18n, Vec) { let mut problems = Vec::new(); let mut locales = vec![bare("en"), bare("pseudo")]; @@ -74,16 +127,8 @@ impl I18n { problems.push(format!("{code}: no plural rules; plurals show in English")); } let forms = plural.as_ref().map_or(1, |(_, c)| c.len()); - let mut rows: HashMap> = HashMap::new(); - for (name, text) in [("common", shared), ("app", app)] { - let (entries, errors) = catalog::parse(text, forms); - problems.extend(errors.into_iter().map(|e| format!("{code}/{name}: {e}"))); - for e in entries { - rows.entry(e.context).or_default().insert(e.source, e.translation); - } - } let offered = offered.iter().any(|o| o.eq_ignore_ascii_case(&code)); - locales.push(Locale { code, rows, chain: Vec::new(), plural, offered }); + locales.push(Locale { code, shared, app, forms, parsed: OnceLock::new(), name: OnceLock::new(), chain: Vec::new(), plural, offered }); } for i in 2..locales.len() { let mut chain = vec![i]; @@ -106,6 +151,12 @@ impl I18n { (I18n { locales, pseudo: Mutex::default(), system: OnceLock::new() }, problems) } + /// Parse every locale and list every problem, `/: line N: …`, for a test or + /// an audit; the same ones [`I18n::new`] leaves to lookups to log. + pub fn check_all(&self) -> Vec { + self.locales.iter().flat_map(|l| l.parsed().problems.iter().cloned()).collect() + } + /// The language with this code (any case): `en`, an offered locale, or in debug builds /// `pseudo`. pub fn from_code(&self, code: &str) -> Option { @@ -169,7 +220,7 @@ impl I18n { .iter() .find_map(|&i| { let locale = self.locales.get(i)?; - let forms = locale.rows.get("@plural")?.get(&key)?; + let forms = locale.parsed().rows.get("@plural")?.get(&key)?; let (rules, categories) = locale.plural.as_ref()?; let category = rules.select(n).ok()?; // A category whole numbers up to 1000 never reach (French `many`) takes the last form. @@ -192,21 +243,20 @@ impl I18n { std::iter::once(Lang::EN).chain(embedded.into_iter().map(Lang)).collect() } - /// The language's name in itself, for a language picker: its shared catalog's - /// `@id language.name` row, or the code when there is none. + /// The language's name in itself, for a language picker: its `@id language.name` row (the + /// app's catalog over the shared one), or the code when there is none. Doesn't parse the + /// locale. pub fn name(&self, lang: Lang) -> &str { if lang == Lang::EN { return "English"; } - self.locale(lang).and_then(|l| l.rows.get("@id")?.get("language.name")).map_or_else(|| self.code(lang), String::as_str) + self.locale(lang).and_then(Locale::language_name).unwrap_or_else(|| self.code(lang)) } - /// Whether the language is written right to left. Putting text into display order is the - /// app's job. + /// Whether the language is written right to left ([`direction`]). Putting text into display + /// order is the app's job. pub fn rtl(&self, lang: Lang) -> bool { - let code = self.code(lang); - let language = code.split('-').next().unwrap_or(code); - matches!(language, "ar" | "ckb" | "dv" | "fa" | "he" | "ps" | "sd" | "ug" | "ur" | "yi") + direction(self.code(lang)) == Direction::Rtl } /// The language for a system locale tag (`fr_CA.UTF-8`, `zh-Hant-MO`); `C` and `POSIX` are @@ -257,7 +307,7 @@ impl I18n { fn find(&self, lang: Lang, keys: &[(&str, &str)]) -> Option<&str> { let chain = self.locale(lang).map(|l| l.chain.as_slice())?; chain.iter().find_map(|&i| { - let rows = &self.locales.get(i)?.rows; + let rows = &self.locales.get(i)?.parsed().rows; keys.iter().find_map(|(context, source)| rows.get(*context)?.get(*source).map(String::as_str)) }) } @@ -432,7 +482,9 @@ fn fill(template: &str, args: &[(&str, &str)], isolated: bool) -> String { /// Chinese without a script picks one by region; Portuguese falls back to `pt-br` last, when /// there is no `pt`. A glibc script modifier (`sr_RS@latin`) becomes the script subtag. With a /// script (`sr-Latn-RS`), a code without one is tried only when that is its language's usual -/// script, so Latin Serbian never picks Cyrillic `sr`, while `sr-Cyrl-RS` does. +/// script, so Latin Serbian never picks Cyrillic `sr`, while `sr-Cyrl-RS` does. A region that +/// implies a script (CLDR likely subtags) counts as that script after the tag as written: +/// `pa-PK` → `pa-pk`, `pa-arab-pk`, `pa-arab`. fn candidates(tag: &str) -> Vec { let tag = tag.trim().to_ascii_lowercase(); let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-"); @@ -449,6 +501,16 @@ fn candidates(tag: &str) -> Vec { parts.insert(1, s); } let script = parts.get(1).copied().filter(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic())); + // Chinese picks its script by region below, keeping plain `zh` as the last resort. + if script.is_none() + && primary != "zh" + && let Some(implied) = parts.get(1).filter(|r| is_region(r)).and_then(|r| scripts::region_script(primary, r)) + { + let mut out = vec![parts.join("-")]; + parts.insert(1, implied); + out.extend(candidates(&parts.join("-"))); + return out; + } let mut out: Vec = (1..=parts.len()).rev().map(|n| parts.get(..n).unwrap_or_default().join("-")).collect(); if let Some(s) = script && usual_script(primary).is_some_and(|usual| usual != s) @@ -456,7 +518,8 @@ fn candidates(tag: &str) -> Vec { out.retain(|c| c.split('-').any(|p| p == s)); } if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) { - let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" }; + let hant = parts.iter().skip(1).any(|p| scripts::region_script("zh", p) == Some("hant")); + let script = if hant { "zh-hant" } else { "zh-hans" }; out.insert(out.len().saturating_sub(1), script.to_owned()); } if primary == "pt" && !out.iter().any(|c| c == "pt-br") { @@ -485,10 +548,10 @@ fn marked(text: &str) -> String { // ponytail: the multi-script languages a Craft app could plausibly ship; add a row with the locale. fn usual_script(language: &str) -> Option<&'static str> { Some(match language { - "be" | "mn" | "sr" => "cyrl", + "be" | "kk" | "ky" | "mn" | "sr" | "tg" => "cyrl", "zh" => "hans", - "az" | "bs" | "ha" | "uz" => "latn", - "ks" | "sd" => "arab", + "az" | "bs" | "ha" | "ku" | "ms" | "uz" => "latn", + "ks" | "sd" | "ug" => "arab", "pa" => "guru", _ => return None, }) @@ -501,7 +564,17 @@ fn is_region(subtag: &str) -> bool { /// A locale with no catalogs (English, the pseudo locale). fn bare(code: &str) -> Locale { - Locale { code: code.to_owned(), rows: HashMap::new(), chain: Vec::new(), plural: None, offered: false } + Locale { + code: code.to_owned(), + shared: "", + app: "", + forms: 1, + parsed: OnceLock::new(), + name: OnceLock::new(), + chain: Vec::new(), + plural: None, + offered: false, + } } /// `code`'s CLDR cardinal rules and the categories whole numbers 0..=1000 reach, in CLDR order @@ -539,6 +612,15 @@ fn rank(c: &PluralCategory) -> u8 { mod tests { use super::*; + impl I18n { + /// The embedded locales parsed so far, by code. + fn parsed(&self) -> Vec<&str> { + let mut codes: Vec<&str> = self.locales.iter().skip(2).filter(|l| l.parsed.get().is_some()).map(|l| l.code.as_str()).collect(); + codes.sort_unstable(); + codes + } + } + static E: &Embedded = &[ ("fr", "\tSave\tEnregistrer\n\tOpen\tOuvrir\nmenu\tOpen\tOuvrir le menu\n@id\tfile.save\tEnregistrer le fichier\n", "\tSave\tSauvegarder\n"), ("pt", "\tSave\tGuardar\n\tUndo\tDesfazer\n", ""), @@ -602,11 +684,9 @@ mod tests { #[test] fn bad_rows_skipped_and_reported() { - let (i, problems) = all(E); - assert_eq!(problems.len(), 2, "{problems:?}"); - assert!(problems[0].starts_with("de/common: line 1:"), "{problems:?}"); - assert!(problems[1].starts_with("de/common: line 2:"), "{problems:?}"); + let i = i18n(); assert_eq!(i.t(lang(&i, "de"), "Undo"), "Rückgängig"); + assert_eq!(i.check_all().len(), 2); } #[test] @@ -776,6 +856,8 @@ mod tests { ("zh", Some("zh-hans")), ("zh-Hant-MO", Some("zh-hant")), ("zh_CN", Some("zh-hans")), + ("zh-US", Some("zh-hant")), + ("zh_SG", Some("zh-hans")), ("C", Some("en")), ("POSIX", Some("en")), ("en_US", Some("en")), @@ -787,6 +869,20 @@ mod tests { } } + #[test] + fn region_implies_script() { + assert_eq!(candidates("pa-PK"), ["pa-pk", "pa-arab-pk", "pa-arab"]); + assert_eq!(candidates("pa_PK.UTF-8"), ["pa-pk", "pa-arab-pk", "pa-arab"]); + assert_eq!(candidates("pa-IN"), ["pa-in", "pa"]); + assert_eq!(candidates("sd-IN"), ["sd-in", "sd-deva-in", "sd-deva"]); + assert_eq!(candidates("pa-Guru-PK"), ["pa-guru-pk", "pa-guru", "pa"]); + assert_eq!(candidates("zh-US"), ["zh-us", "zh-hant", "zh"]); + assert_eq!(candidates("zh-SG"), ["zh-sg", "zh-hans", "zh"]); + assert_eq!(candidates("kk-CN"), ["kk-cn", "kk-arab-cn", "kk-arab"]); + assert_eq!(candidates("kk-KZ"), ["kk-kz", "kk"]); + assert_eq!(candidates("ug-KZ"), ["ug-kz", "ug-cyrl-kz", "ug-cyrl"]); + } + #[test] fn match_tags_takes_first_match() { let i = all(L).0; @@ -915,8 +1011,8 @@ mod tests { #[test] fn duplicate_row_keeps_the_first() { - let (i, problems) = all(&[("fr", "\tSave\tEnregistrer\n\tSave\tSauver\n", "")]); - assert_eq!(problems, ["fr/common: line 2: defined twice (same context and source text); the first is kept"]); + let i = all(&[("fr", "\tSave\tEnregistrer\n\tSave\tSauver\n", "")]).0; + assert_eq!(i.check_all(), ["fr/common: line 2: defined twice (same context and source text); the first is kept"]); assert_eq!(i.t(lang(&i, "fr"), "Save"), "Enregistrer"); } @@ -952,6 +1048,67 @@ mod tests { assert!(i.languages().into_iter().skip(1).all(|l| i.rtl(l))); } + #[test] + fn check_all_reports_every_bad_row() { + let (i, problems) = all(E); + assert!(i.parsed().is_empty(), "new parses nothing"); + assert!(problems.iter().all(|p| !p.contains("line")), "{problems:?}"); + let found = i.check_all(); + assert_eq!(found.len(), 2, "{found:?}"); + assert!(found[0].starts_with("de/common: line 1:"), "{found:?}"); + assert!(found[1].starts_with("de/common: line 2:"), "{found:?}"); + assert_eq!(i.check_all(), found); + assert_eq!(i.parsed(), ["de", "fr", "pt", "pt-br"]); + } + + #[test] + fn lookup_parses_only_what_it_reaches() { + static LAZY: &Embedded = &[ + ("pt", "\tUndo\tDesfazer\n", ""), + ("pt-br", "\tSave\tSalvar\n", ""), + ("de", "\tUndo\tRückgängig\n", ""), + ("fr", "\tUndo\tAnnuler\n", ""), + ]; + let i = all(LAZY).0; + assert!(i.parsed().is_empty()); + assert_eq!(i.t(lang(&i, "pt-br"), "Save"), "Salvar"); + assert_eq!(i.parsed(), ["pt-br"]); + assert_eq!(i.t(lang(&i, "pt-br"), "Undo"), "Desfazer"); + assert_eq!(i.parsed(), ["pt", "pt-br"]); + assert_eq!(i.t(lang(&i, "pt-br"), "Undo"), "Desfazer"); + assert_eq!(i.parsed(), ["pt", "pt-br"]); + assert_eq!(i.t(Lang::EN, "Undo"), "Undo"); + assert_eq!(i.t(Lang::PSEUDO, "Undo"), "⟦Undo⟧"); + assert_eq!(i.parsed(), ["pt", "pt-br"]); + } + + #[test] + fn name_reads_escapes_without_parsing() { + let i = all(&[("fr", "@id\tlanguage.name\tFran\\\\ais\\tx\n", ""), ("de", "\tA\tB\n@id\tlanguage.name\tDeutsch\n", "")]).0; + assert_eq!(i.name(lang(&i, "fr")), "Fran\\ais\tx"); + assert_eq!(i.name(lang(&i, "de")), "Deutsch"); + assert!(i.parsed().is_empty()); + } + + #[test] + fn app_name_overrides_shared() { + let i = all(&[ + ("fr", "@id\tlanguage.name\tFrançais\n", "@id\tlanguage.name\tFrançais (app)\n"), + ("de", "@id\tlanguage.name\tDeutsch\n", "\tA\tB\n"), + ("es", "", ""), + ]) + .0; + assert_eq!(i.name(lang(&i, "fr")), "Français (app)"); + assert_eq!(i.name(lang(&i, "de")), "Deutsch"); + assert_eq!(i.name(lang(&i, "es")), "es"); + } + + #[test] + fn i18n_is_send_and_sync() { + fn check() {} + check::(); + } + /// Prints what this machine reports, so CI logs show `system_lang` on each platform. #[test] fn system_lang_reads_the_system() { diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs new file mode 100644 index 00000000..1ae0f29b --- /dev/null +++ b/crates/i18n/src/scripts.rs @@ -0,0 +1,455 @@ +//! The scripts a language is written in, its direction, and which regional forms its Han +//! characters take, for picking fonts and laying out the interface. + +/// A writing system, by its ISO 15924 code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] +pub enum Script { + Arab, + Armn, + Beng, + Cyrl, + Deva, + Ethi, + Geor, + Grek, + Gujr, + Guru, + Hang, + Hani, + Hebr, + Hira, + Kana, + Khmr, + Knda, + Laoo, + Latn, + Mlym, + Mong, + Mymr, + Orya, + Sinh, + Syrc, + Taml, + Telu, + Thaa, + Thai, + Tibt, +} + +impl Script { + /// The ISO 15924 code, `Latn`. + pub fn code(self) -> &'static str { + match self { + Script::Arab => "Arab", + Script::Armn => "Armn", + Script::Beng => "Beng", + Script::Cyrl => "Cyrl", + Script::Deva => "Deva", + Script::Ethi => "Ethi", + Script::Geor => "Geor", + Script::Grek => "Grek", + Script::Gujr => "Gujr", + Script::Guru => "Guru", + Script::Hang => "Hang", + Script::Hani => "Hani", + Script::Hebr => "Hebr", + Script::Hira => "Hira", + Script::Kana => "Kana", + Script::Khmr => "Khmr", + Script::Knda => "Knda", + Script::Laoo => "Laoo", + Script::Latn => "Latn", + Script::Mlym => "Mlym", + Script::Mong => "Mong", + Script::Mymr => "Mymr", + Script::Orya => "Orya", + Script::Sinh => "Sinh", + Script::Syrc => "Syrc", + Script::Taml => "Taml", + Script::Telu => "Telu", + Script::Thaa => "Thaa", + Script::Thai => "Thai", + Script::Tibt => "Tibt", + } + } + + /// Whether text in this script runs right to left. + pub fn rtl(self) -> bool { + matches!(self, Script::Arab | Script::Hebr | Script::Syrc | Script::Thaa) + } +} + +/// Which way a language's text, and an interface in it, runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Direction { + Ltr, + Rtl, +} + +/// The regional glyph forms Han characters take, as in the Noto CJK face names (SC, TC, HK, JP, +/// KR). The same code point is drawn differently in each, so a font stack picks its Han face by +/// this, not by script. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Han { + /// Simplified Chinese, mainland China and Singapore (SC). + Simplified, + /// Traditional Chinese, Taiwan (TC). + Traditional, + /// Traditional Chinese, Hong Kong and Macau (HK). + HongKong, + /// Japanese kanji (JP). + Japanese, + /// Korean hanja (KR). + Korean, +} + +/// The scripts a language tag's interface text is written in (`ja` → Hani, Hira, Kana; `sr` → +/// Cyrl; `sr-latn` → Latn), the main one first. Any case and glibc form (`sr_RS.UTF-8@latin`). A +/// script subtag wins, then a region that implies a script (`pa-PK` → Arab), then the language's +/// usual script. Latin product names and units appear in every language, so Latn is listed only +/// where it is the language's own script. A language not in the table is taken to be written in +/// Latin. +// ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. +pub fn scripts(tag: &str) -> &'static [Script] { + use Script::*; + let tag = Subtags::parse(tag); + if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { + return s; + } + if let Some(s) = tag.region.as_deref().and_then(|region| region_script(&tag.language, region)).and_then(subtag_scripts) { + return s; + } + match tag.language.as_str() { + "ja" => &[Hani, Hira, Kana], + "ko" => &[Hang, Hani], + "zh" | "yue" | "wuu" => &[Hani], + "be" | "bg" | "ba" | "ce" | "cv" | "kk" | "ky" | "mk" | "mn" | "ru" | "sah" | "sr" | "tg" | "tt" | "uk" => &[Cyrl], + "el" => &[Grek], + "ar" | "ckb" | "fa" | "ks" | "ps" | "sd" | "ug" | "ur" => &[Arab], + "he" | "yi" => &[Hebr], + "syr" => &[Syrc], + "dv" => &[Thaa], + "ka" => &[Geor], + "hy" => &[Armn], + "hi" | "mr" | "ne" | "sa" | "kok" | "mai" | "bho" => &[Deva], + "bn" | "as" | "mni" => &[Beng], + "pa" => &[Guru], + "gu" => &[Gujr], + "or" => &[Orya], + "ta" => &[Taml], + "te" => &[Telu], + "kn" => &[Knda], + "ml" => &[Mlym], + "si" => &[Sinh], + "th" => &[Thai], + "lo" => &[Laoo], + "km" => &[Khmr], + "my" => &[Mymr], + "am" | "ti" => &[Ethi], + "bo" | "dz" => &[Tibt], + _ => &[Latn], + } +} + +/// The direction of a language tag's text: right to left when its main script is. +pub fn direction(tag: &str) -> Direction { + match scripts(tag).first() { + Some(s) if s.rtl() => Direction::Rtl, + _ => Direction::Ltr, + } +} + +/// The direction of a paragraph of text, from its first strong character (UAX #9 rules P2 and +/// P3): the contents of isolates (LRI, RLI or FSI up to the matching PDI) are skipped, and the +/// search stops at the first paragraph separator. `None` when the paragraph has no strong +/// character; P3 then reads it as left to right, and an app may use the interface direction. +pub fn base_direction(text: &str) -> Option { + match unicode_bidi::get_base_direction(text) { + unicode_bidi::Direction::Ltr => Some(Direction::Ltr), + unicode_bidi::Direction::Rtl => Some(Direction::Rtl), + unicode_bidi::Direction::Mixed => None, + } +} + +/// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → +/// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters +/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO`, `zh-Hant-HK` and `yue` → HongKong; `ja` → +/// Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the choice +/// to the font stack. +pub fn han(tag: &str) -> Option { + let tag = Subtags::parse(tag); + let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); + // `Hani` names Han without saying which forms, so the region still decides. + let script = tag.script.as_deref().filter(|s| *s != "hani"); + let script = script.or_else(|| region_script(&tag.language, tag.region.as_deref()?)); + match (tag.language.as_str(), script) { + ("ja", _) => Some(Han::Japanese), + ("ko", _) => Some(Han::Korean), + ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), + ("yue", Some("hant")) => Some(if tag.region.as_deref() == Some("tw") { Han::Traditional } else { Han::HongKong }), + ("zh" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), + // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. + ("yue", _) => Some(Han::HongKong), + ("zh" | "wuu", _) => Some(Han::Simplified), + _ => None, + } +} + +/// The script subtag of a language in a region where CLDR's likely subtags give it a script +/// other than its usual one: `pa-PK` → `arab` (`pa-Arab-PK`), `zh-TW` → `hant`. +// ponytail: mn-CN → Mong is left out until vertical Mongolian layout is supported. +pub(crate) fn region_script(language: &str, region: &str) -> Option<&'static str> { + Some(match (language, region) { + ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => "arab", + ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => "arab", + ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => "cyrl", + ("ky", "tr") | ("sr", "me" | "ro" | "tr") => "latn", + ("sd", "in") => "deva", + ("zh", "au" | "bn" | "gb" | "gf" | "hk" | "id" | "mo" | "pa" | "pf" | "ph" | "sr" | "th" | "tw" | "us" | "vn") => "hant", + ("yue", "cn") => "hans", + _ => return None, + }) +} + +/// The scripts a script subtag stands for, including the CLDR unions (`jpan`, `kore`). `None` for +/// one not in [`Script`], so the language's own scripts apply. +fn subtag_scripts(subtag: &str) -> Option<&'static [Script]> { + use Script::*; + Some(match subtag { + "hans" | "hant" | "hani" => &[Hani], + "jpan" => &[Hani, Hira, Kana], + "kore" => &[Hang, Hani], + "arab" => &[Arab], + "armn" => &[Armn], + "beng" => &[Beng], + "cyrl" => &[Cyrl], + "deva" => &[Deva], + "ethi" => &[Ethi], + "geor" => &[Geor], + "grek" => &[Grek], + "gujr" => &[Gujr], + "guru" => &[Guru], + "hang" => &[Hang], + "hebr" => &[Hebr], + "khmr" => &[Khmr], + "knda" => &[Knda], + "laoo" => &[Laoo], + "latn" => &[Latn], + "mlym" => &[Mlym], + "mong" => &[Mong], + "mymr" => &[Mymr], + "orya" => &[Orya], + "sinh" => &[Sinh], + "syrc" => &[Syrc], + "taml" => &[Taml], + "telu" => &[Telu], + "thaa" => &[Thaa], + "thai" => &[Thai], + "tibt" => &[Tibt], + _ => return None, + }) +} + +/// A language tag's language, script and region subtags, lowercase. +struct Subtags { + language: String, + script: Option, + region: Option, +} + +impl Subtags { + /// `zh-Hant-HK`, `zh_TW.UTF-8` or `sr_RS@latin` (glibc's script modifier). + fn parse(tag: &str) -> Subtags { + let tag = tag.trim().to_ascii_lowercase(); + let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-"); + let mut parts = base.split('-').filter(|p| !p.is_empty()); + let language = parts.next().unwrap_or("").to_owned(); + let mut script = match tag.split_once('@').map(|(_, m)| m) { + Some("latin") => Some("latn".to_owned()), + Some("cyrillic") => Some("cyrl".to_owned()), + _ => None, + }; + let mut region = None; + for part in parts { + if part.len() == 4 && part.bytes().all(|b| b.is_ascii_alphabetic()) { + script.get_or_insert_with(|| part.to_owned()); + } else if super::is_region(part) { + region = Some(part.to_owned()); + break; + } + } + Subtags { language, script, region } + } +} + +#[cfg(test)] +mod tests { + use super::Script::*; + use super::*; + + #[test] + fn base_direction_of_text() { + assert_eq!(base_direction("Hello"), Some(Direction::Ltr)); + assert_eq!(base_direction("123 שלום"), Some(Direction::Rtl)); + assert_eq!(base_direction("(مرحبا) hi"), Some(Direction::Rtl)); + // The isolate's Hebrew is skipped; the Latin after it decides. + assert_eq!(base_direction("\u{2067}שלום\u{2069} hello"), Some(Direction::Ltr)); + assert_eq!(base_direction("\u{2068}abc\u{2069} שלום"), Some(Direction::Rtl)); + // Only the first paragraph counts. + assert_eq!(base_direction("123\nשלום"), None); + assert_eq!(base_direction("1920 × 1080"), None); + assert_eq!(base_direction(""), None); + } + + /// Every locale the craft-i18n catalogs or an app ship, as the apps spell them. + #[test] + fn shipped_locales() { + for (tag, want, han_forms) in [ + ("en", &[Latn][..], None), + ("ar", &[Arab], None), + ("bg", &[Cyrl], None), + ("cs", &[Latn], None), + ("da", &[Latn], None), + ("de", &[Latn], None), + ("el", &[Grek], None), + ("es", &[Latn], None), + ("es-419", &[Latn], None), + ("et", &[Latn], None), + ("fi", &[Latn], None), + ("fr", &[Latn], None), + ("hr", &[Latn], None), + ("hu", &[Latn], None), + ("id", &[Latn], None), + ("it", &[Latn], None), + ("ja", &[Hani, Hira, Kana], Some(Han::Japanese)), + ("ka", &[Geor], None), + ("kk", &[Cyrl], None), + ("ko", &[Hang, Hani], Some(Han::Korean)), + ("lt", &[Latn], None), + ("nl", &[Latn], None), + ("pl", &[Latn], None), + ("pt", &[Latn], None), + ("pt-br", &[Latn], None), + ("pt_BR", &[Latn], None), + ("ru", &[Cyrl], None), + ("sl", &[Latn], None), + ("sr", &[Cyrl], None), + ("sr-latn", &[Latn], None), + ("sv", &[Latn], None), + ("te", &[Telu], None), + ("tr", &[Latn], None), + ("uk", &[Cyrl], None), + ("vi", &[Latn], None), + ("zh", &[Hani], Some(Han::Simplified)), + ("zh-cn", &[Hani], Some(Han::Simplified)), + ("zh-CN", &[Hani], Some(Han::Simplified)), + ("zh-hans", &[Hani], Some(Han::Simplified)), + ("zh-Hans", &[Hani], Some(Han::Simplified)), + ("zh-hant", &[Hani], Some(Han::Traditional)), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + assert_eq!(han(tag), han_forms, "{tag}"); + let rtl = tag == "ar"; + assert_eq!(direction(tag), if rtl { Direction::Rtl } else { Direction::Ltr }, "{tag}"); + } + } + + #[test] + fn regional_han() { + for (tag, want) in [ + ("zh-TW", Han::Traditional), + ("zh_TW.UTF-8", Han::Traditional), + ("zh-Hant-TW", Han::Traditional), + ("zh-HK", Han::HongKong), + ("zh-MO", Han::HongKong), + ("zh-Hant-HK", Han::HongKong), + ("zh-hant-hk", Han::HongKong), + ("zh-Hans-HK", Han::Simplified), + ("zh-SG", Han::Simplified), + ("zh-US", Han::Traditional), + ("zh_AU.UTF-8", Han::Traditional), + ("zh-GB", Han::Traditional), + ("zh-Hans-US", Han::Simplified), + ("zh-MY", Han::Simplified), + ("yue-CN", Han::Simplified), + ("yue-HK", Han::HongKong), + ("zh-Hans-SG", Han::Simplified), + ("yue", Han::HongKong), + ("yue-Hant", Han::HongKong), + ("yue-Hant-TW", Han::Traditional), + ("zh-Hani-HK", Han::HongKong), + ("zh-Hani", Han::Simplified), + ("yue-Hans", Han::Simplified), + ("ja-JP", Han::Japanese), + ("ko_KR.UTF-8", Han::Korean), + ] { + assert_eq!(han(tag), Some(want), "{tag}"); + } + assert_eq!(han("en"), None); + assert_eq!(han(""), None); + } + + #[test] + fn script_subtag_wins() { + for (tag, want) in [ + ("sr-Latn-RS", &[Latn][..]), + ("sr_RS@latin", &[Latn]), + ("sr-Cyrl", &[Cyrl]), + ("sr_RS.UTF-8", &[Cyrl]), + ("az-Cyrl", &[Cyrl]), + ("pa-Arab", &[Arab]), + ("pa", &[Guru]), + ("mn-Mong", &[Mong]), + ("sd-Deva", &[Deva]), + ("fr-Zyyy", &[Latn]), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + } + assert_eq!(direction("pa-Arab"), Direction::Rtl); + assert_eq!(direction("sd-Deva"), Direction::Ltr); + assert_eq!(direction("he_IL.UTF-8"), Direction::Rtl); + } + + /// CLDR likely subtags: a region can switch a language to another script. + #[test] + fn region_implies_script() { + for (tag, want) in [ + ("pa-PK", &[Arab][..]), + ("pa_PK.UTF-8", &[Arab]), + ("pa-IN", &[Guru]), + ("uz-AF", &[Arab]), + ("uz-UZ", &[Latn]), + ("uz-CN", &[Cyrl]), + ("az-IR", &[Arab]), + ("az-AZ", &[Latn]), + ("sd-IN", &[Deva]), + ("sd-PK", &[Arab]), + ("ks-IN", &[Arab]), + ("ms-CC", &[Arab]), + ("ms-BN", &[Latn]), + ("sr-ME", &[Latn]), + ("kk-CN", &[Arab]), + ("ug-KZ", &[Cyrl]), + ("pa-Guru-PK", &[Guru]), + ("sd-Arab-IN", &[Arab]), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + } + assert_eq!(direction("pa-PK"), Direction::Rtl); + assert_eq!(direction("uz-AF"), Direction::Rtl); + assert_eq!(direction("az-IR"), Direction::Rtl); + assert_eq!(direction("sd-IN"), Direction::Ltr); + assert_eq!(direction("ug-KZ"), Direction::Ltr); + } + + /// The usual scripts tag matching relies on agree with the table. + #[test] + fn agrees_with_usual_script() { + for language in ["be", "kk", "ky", "mn", "sr", "tg", "zh", "az", "bs", "ha", "ku", "ms", "uz", "ks", "sd", "ug", "pa"] { + let usual = crate::usual_script(language).and_then(subtag_scripts); + assert_eq!(usual, Some(scripts(language)), "{language}"); + } + } +} diff --git a/crates/media/README.md b/crates/media/README.md index f80b920d..a408f04d 100644 --- a/crates/media/README.md +++ b/crates/media/README.md @@ -39,8 +39,42 @@ craft-media = { git = "https://github.com/storytold/craft-libs", rev = " The code comes from FilmCraft's `filmcraft-bitstream`, `filmcraft-isobmff` and `filmcraft-matroska` crates at [storytold/filmcraft@6c3948a](https://github.com/storytold/filmcraft/commit/6c3948a36c80e4996d924766ba7157eec4fca85e). -Every public item keeps its name, signature and behavior for valid input, so FilmCraft can turn -all three crates into re-exports (`pub use craft_media::isobmff::*;`). The differences: +Public items keep their names and signatures, so FilmCraft can turn all three crates into +re-exports (`pub use craft_media::isobmff::*;`), except for the renames below. Our own names use +US spelling; names taken from a specification keep its spelling. + +Renamed: + +| FilmCraft | craft-media | +|---|---| +| `matroska::Colour` | `matroska::Color` | +| `matroska::VideoInfo::colour` | `matroska::VideoInfo::color` | +| `matroska::VideoInfo::colour_space` | `matroska::VideoInfo::color_space` | + +The element IDs `element_ids::COLOUR` and `COLOUR_SPACE` and the field +`isobmff::VpcConfig::colour_primaries` keep the spellings of the Matroska and VP9 specifications. + +Output that differs on valid input: + +- Matroska lacing applies only to tracks with a `DefaultDuration`, and a lace ends at a timestamp + gap. Readers space the frames of a lace by `DefaultDuration`, so FilmCraft's laces moved frames + that followed a gap. +- The sample budget covers the whole file, so a multi-track file with more than 2²⁶ samples in + total fails with `TooLarge` (ISO BMFF: all tracks and fragments; Matroska: all tracks, and at + most 2¹⁶ TrackEntries). FilmCraft capped ISO BMFF per track and Matroska not at all. +- MOV and MP4 PCM chunks end at 1 MiB, as ffmpeg writes them; FilmCraft wrote + each contiguous run of one track as one chunk. The reader splits a PCM chunk above 16 MiB into + samples of at most 16 MiB; FilmCraft made it one sample, cut at 4 GiB. +- MOV PCM at 65,536 Hz and above, and unsigned 16-bit PCM, is written as `lpcm` (sound + description v2), and signed 8-bit PCM as `twos`. FilmCraft wrote 0 Hz for the former and + labeled the signedness wrongly for the latter two. `raw ` under a sound handler reads as PCM. +- Matroska `BlockDuration` is rounded to the nearest tick; FilmCraft truncated it. +- A Matroska `TimestampScale` above 2³² is ignored, like a zero scale. +- `matroska::MkvWriter` writes `MuxingApp` = `craft-media`, and `MuxOptions::default()` has + `writing_app` = `craft-media` (FilmCraft: `filmcraft-matroska` and `filmcraft`). Apps set + `writing_app` to their own name. + +Other differences: - `bitstream::BitReader::read_ue_plus` (from DeckCraft's copy) is new. - `isobmff::boxes` is new: FilmCraft's private box iterator and byte reader, made public (see @@ -49,15 +83,21 @@ all three crates into re-exports (`pub use craft_media::isobmff::*;`). The diffe without a debug assertion: `peek` and `read_bits` return the low 32 bits of the field (use `read_bits_u64` for wide fields), and `write_bits` writes leading zero bits, at most 64 bits in all. -- `bitstream::length_prefixed_nals` returns `BitError::Eof` for a zero `length_size` on non-empty - data and cannot overflow on a hostile length prefix. +- `bitstream::length_prefixed_nals` returns the new `BitError::InvalidNalLengthSize` for a + `length_size` outside 1 to 4 (as FilmCraft does since filmcraft#2953) and cannot overflow on a + hostile length prefix. - `isobmff::PcmConfig::bytes_per_frame` saturates at `u32::MAX` for a hostile channel count. - `matroska`: a cluster timestamp above `i64::MAX` saturates at `i64::MAX` (FilmCraft wraps it to a negative value), and adding a block's relative timestamp saturates instead of overflowing (a debug-build panic in FilmCraft). -- `matroska::MkvWriter` writes `MuxingApp` = `craft-media`, and `MuxOptions::default()` has - `writing_app` = `craft-media` (FilmCraft: `filmcraft-matroska` and `filmcraft`). Apps set - `writing_app` to their own name. +- `matroska::Error::TooLarge` is new: indexing a file past the limits above returns it. +- `matroska::MuxOptions::max_lace_frames` is clamped to 1 to 256; FilmCraft wrote a corrupt + frame count above 256. +- `isobmff::Mp4Writer::set_sample_entry` returns an error when the new entry changes the codec, + the media type or the PCM frame size, and `Error::NoSuchTrack` for a bad index. FilmCraft + accepted any entry. +- `isobmff::Mp4Writer::write_sample` returns an error when a track would pass 2³² − 1 samples + (PCM: frames), the most `stsz` can count; FilmCraft clamped the count. - `matroska::ByteSource` stays a separate trait from `isobmff::ByteSource`, with the same shape. FilmCraft implements both for one type, which a single shared trait would reject as conflicting impls. @@ -109,7 +149,7 @@ let bytes = mp4.read_sample(&file, v, key)?; // raw sample (AVCC/HVCC length- raw for the caller to interpret. - **Uncompressed PCM** (`lpcm`, `sowt`, `twos`, `in24`, `in32`, `fl32`, `fl64`, `raw `, `ipcm`, `fpcm`) is merged to one `Sample` per chunk (`pcm_chunked = true`), with `duration` in frames. This avoids one - table entry per audio frame. + table entry per audio frame. A chunk above 16 MiB becomes several samples of at most 16 MiB. #### Parsed boxes `ftyp`, `moov/mvhd`, `trak/tkhd`, `edts/elst` (v0/v1), `tref`, `mdia/mdhd` (v0/v1, ISO and Mac language @@ -145,7 +185,8 @@ Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `ga #### Robustness The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are checked against the bytes in their box before anything is allocated. A constant-size `stsz` count is -clamped to what fits in the file. `trun` counts are checked, and each track is capped at 2²⁶ samples. +clamped to what fits in the file. `trun` counts are checked, and the whole file (all tracks and +fragments) is capped at 2²⁶ samples. `moov`/`moof` are capped at 1 GiB. Compressed movie headers (`cmov`) are rejected with `Error::Unsupported`. @@ -170,7 +211,8 @@ let bytes = w.finish_faststart()?.into_inner(); // or finish() for moov-at-end - Brands: `Brand::Mp4` writes `isom` with `isom iso2 avc1 mp41 mp42`. `Brand::Mov` writes `qt `, uses QuickTime `hdlr`/Pascal names, and sets `wide` as the placeholder box. - Tables written: `stts`, `ctts` (v1 when an offset is negative), `stss` (omitted when every sample is - sync), `stsc`, `stsz` (constant or table), `stco`/`co64`. Consecutive samples of one track share a chunk. + sync), `stsc`, `stsz` (constant or table), `stco`/`co64`. Consecutive samples of one track share a chunk; + a PCM chunk ends at 1 MiB. - Sample entries come from `SampleEntry` (`avc`, `hevc`, `prores`, `jpeg`, `aac`, `pcm`, or any demuxed entry). Remuxing copies codec config, `colr`, `pasp`, `clap`, `fiel`, `gama` and `btrt`. PCM is written as `sowt`/`twos` (16-bit) or `raw ` (8-bit) or sound description v2 `lpcm` in MOV, and as @@ -224,7 +266,7 @@ for b in BoxIter::new(&bytes) { - `sidx`/`mfra` are ignored, because the demuxer scans every top-level box. `stsd` entries with a QuickTime color table are not decoded. - Subtitle and text tracks (`tx3g`, `c608`, `wvtt`) and `mp4v` show up as `Unknown`. -- Muxer: one `mdat`, no chunk-size or duration limits (the caller controls interleaving), no `sdtp`, +- Muxer: one `mdat`, no chunk-size or duration limits outside PCM (the caller controls interleaving), no `sdtp`, `sgpd`/`sbgp` or `cslg` output, ISO-style `meta/ilst` metadata is not written, and the fragmented writer has no PCM, `sidx` or `mfra`. @@ -274,6 +316,10 @@ while let Some(p) = d.next_packet()? { // file order, all tracks - **Damage:** an undecodable or oversized element starts a byte scan for the next plausible level-1 element (a Cluster whose first child is Timestamp, or a sized metadata element); a truncated final block is dropped. Problems are listed in `MkvFile::warnings`. +- **Limits:** indexing stops with `Error::TooLarge` past 2²⁶ frames in the file, past one frame + per file byte (each frame counts its stored size, and at least one byte), or past 2¹⁶ + TrackEntries. Level-1 elements read at most the file's length in all, so SeekHead entries that + point into one another are skipped with a warning. - **Seeking** (`Demuxer::seek(track, time_ns)`): uses the sample index when present, otherwise Cues (refined by scanning the cued cluster for the exact keyframe), otherwise builds the index by scanning all clusters. `Demuxer::keyframe_index(track)` returns the keyframe list. @@ -321,7 +367,8 @@ while let Some(p) = d.next_packet()? { // file order, all tracks keyframe, data, duration_ns)` per frame, then `finish()`. It writes the EBML header, Segment, SeekHead, Info (with Duration), Tracks, Clusters of SimpleBlocks (a new cluster at a video keyframe after `cluster_duration_ns`), BlockGroups for frames whose duration differs from the track's -`default_duration_ns`, optional audio lacing (Xiph, EBML, fixed-size), and Cues (video keyframes, +`default_duration_ns`, optional audio lacing (Xiph, EBML, fixed-size; for tracks with a +`default_duration_ns`, at most 256 frames, ending at a timestamp gap), and Cues (video keyframes, or the first frame of each track per cluster). `MuxOptions::doc_type` = `"webm"` writes WebM. CodecPrivate is fixed when the writer is created, so a codec whose configuration arrives with the first frame (AV1 from `rav1e`) creates the writer after that frame. diff --git a/crates/media/src/bitstream.rs b/crates/media/src/bitstream.rs index f56f3619..adf166ca 100644 --- a/crates/media/src/bitstream.rs +++ b/crates/media/src/bitstream.rs @@ -13,6 +13,8 @@ pub enum BitError { Eof, /// An Exp-Golomb code longer than 32 leading zeros. InvalidExpGolomb, + /// An AVCC/HVCC NAL length prefix width outside 1 to 4 bytes. + InvalidNalLengthSize, } impl fmt::Display for BitError { @@ -20,6 +22,7 @@ impl fmt::Display for BitError { match self { BitError::Eof => f.write_str("unexpected end of bitstream"), BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"), + BitError::InvalidNalLengthSize => f.write_str("NAL length prefix must be 1 to 4 bytes"), } } } @@ -33,11 +36,15 @@ pub struct BitReader<'a> { data: &'a [u8], /// Position in bits. pos: usize, + /// Bit position of the last set bit (the `rbsp_stop_one_bit`), found once so that + /// [`Self::more_rbsp_data`] does not rescan a long zero suffix on every call. + rbsp_stop: Option, } impl<'a> BitReader<'a> { pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0 } + let rbsp_stop = data.iter().rposition(|&b| b != 0).map(|i| i.saturating_mul(8).saturating_add(7 - data[i].trailing_zeros() as usize)); + Self { data, pos: 0, rbsp_stop } } pub fn data(&self) -> &'a [u8] { self.data @@ -167,22 +174,7 @@ impl<'a> BitReader<'a> { /// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits. pub fn more_rbsp_data(&self) -> bool { - let total = self.data.len().saturating_mul(8); - if self.pos >= total { - return false; - } - // find last set bit (the rbsp_stop_one_bit) - let mut last = None; - for (i, &b) in self.data.iter().enumerate().rev() { - if b != 0 { - last = Some(i.saturating_mul(8).saturating_add(7 - b.trailing_zeros() as usize)); - break; - } - } - match last { - Some(stop) => self.pos < stop, - None => false, - } + self.rbsp_stop.is_some_and(|stop| self.pos < stop) } } @@ -335,15 +327,16 @@ pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> { out } -/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units. -/// A zero `length_size` cannot split non-empty data and returns [`BitError::Eof`]. +/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1 to 4 bytes) into NAL units. +/// Any other `length_size` returns [`BitError::InvalidNalLengthSize`], even for empty data. pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> { + if !(1..=4).contains(&length_size) { + return Err(BitError::InvalidNalLengthSize); + } let mut out = Vec::new(); let mut i = 0; while i < data.len() { - // Compare against the remaining length so a hostile prefix cannot overflow `usize` - // (32-bit on wasm). - if length_size == 0 || length_size > data.len() - i { + if length_size > data.len() - i { return Err(BitError::Eof); } let mut len = 0usize; @@ -472,6 +465,19 @@ mod tests { assert!(!r.more_rbsp_data()); } + #[test] + fn more_rbsp_data_finds_the_stop_bit_before_a_zero_suffix() { + let mut d = vec![0b1010_0000]; + d.resize(1 << 16, 0); + let mut r = BitReader::new(&d); + assert_eq!(r.rbsp_stop, Some(2)); + assert!(r.more_rbsp_data()); + r.skip(2).unwrap(); + assert!(!r.more_rbsp_data()); + assert_eq!(BitReader::new(&[0; 4]).rbsp_stop, None); + assert!(!BitReader::new(&[0; 4]).more_rbsp_data()); + } + #[test] fn crc() { assert_eq!(crc32(b"123456789"), 0xCBF4_3926); @@ -490,13 +496,15 @@ mod tests { } #[test] - fn length_prefixed_rejects_zero_width_and_oversized_length() { - assert_eq!(length_prefixed_nals(&[], 0).unwrap(), Vec::<&[u8]>::new()); - assert_eq!(length_prefixed_nals(&[1, 2, 3], 0), Err(BitError::Eof)); + fn length_prefixed_rejects_invalid_width_and_oversized_length() { + for size in [0, 5, 8, usize::MAX] { + assert_eq!(length_prefixed_nals(&[], size), Err(BitError::InvalidNalLengthSize), "size {size}"); + assert_eq!(length_prefixed_nals(&[0; 16], size), Err(BitError::InvalidNalLengthSize), "size {size}"); + } + // `(lengthSizeMinusOne & 3) + 1` can be 3; such samples still split. assert_eq!(length_prefixed_nals(&[0, 0, 1, 7], 3).unwrap(), vec![&[7u8][..]]); assert_eq!(length_prefixed_nals(&[0xFF, 0xFF, 0xFF, 0xFF, 1], 4), Err(BitError::Eof)); assert_eq!(length_prefixed_nals(&[0, 0, 0, 1, 9], 4).unwrap(), vec![&[9u8][..]]); - assert_eq!(length_prefixed_nals(&[0, 1], usize::MAX), Err(BitError::Eof)); } #[test] diff --git a/crates/media/src/isobmff/codec.rs b/crates/media/src/isobmff/codec.rs index b4935c9f..a0f63962 100644 --- a/crates/media/src/isobmff/codec.rs +++ b/crates/media/src/isobmff/codec.rs @@ -491,7 +491,7 @@ pub struct VpcConfig { pub bit_depth: u8, pub chroma_subsampling: u8, pub full_range: bool, - pub color_primaries: u8, + pub colour_primaries: u8, pub transfer_characteristics: u8, pub matrix_coefficients: u8, pub codec_init: Vec, @@ -512,7 +512,7 @@ impl VpcConfig { let profile = c.u8()?; let level = c.u8()?; let a = c.u8()?; - let color_primaries = c.u8()?; + let colour_primaries = c.u8()?; let transfer_characteristics = c.u8()?; let matrix_coefficients = c.u8()?; let n = c.u16()? as usize; @@ -523,7 +523,7 @@ impl VpcConfig { bit_depth: a >> 4, chroma_subsampling: (a >> 1) & 7, full_range: a & 1 != 0, - color_primaries, + colour_primaries, transfer_characteristics, matrix_coefficients, codec_init, @@ -535,7 +535,7 @@ impl VpcConfig { b.u8(self.profile); b.u8(self.level); b.u8((self.bit_depth << 4) | ((self.chroma_subsampling & 7) << 1) | self.full_range as u8); - b.u8(self.color_primaries); + b.u8(self.colour_primaries); b.u8(self.transfer_characteristics); b.u8(self.matrix_coefficients); b.u16(self.codec_init.len() as u16); @@ -1009,6 +1009,9 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc let _size_of_struct = c.u32()?; a.sample_rate = c.f64()?; a.channels = c.u32()?; + if a.channels > u32::from(u16::MAX) { + return Err(Error::Invalid(format!("QuickTime v2 sound entry with {} channels", a.channels))); + } let _always = c.u32()?; a.const_bits_per_channel = c.u32()?; a.lpcm_flags = c.u32()?; @@ -1052,7 +1055,7 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc }, b"ac-3" => CodecConfig::Ac3 { dac3: get(b"dac3").unwrap_or(&[]).to_vec() }, b"ec-3" => CodecConfig::Eac3 { dec3: get(b"dec3").unwrap_or(&[]).to_vec() }, - b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)), + b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)?), b"ipcm" | b"fpcm" => { let mut pcm = PcmConfig { bits: a.sample_size, @@ -1078,11 +1081,12 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc Ok(SampleEntry { format, data_reference_index, codec: unknown(), video: None, audio: None, bitrate: None }) } -fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig { +fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> Result { let mut p = PcmConfig { channels: a.channels, sample_rate: a.sample_rate, signed: true, ..Default::default() }; match f { b"lpcm" => { - p.bits = a.const_bits_per_channel as u16; + p.bits = u16::try_from(a.const_bits_per_channel) + .map_err(|_| Error::Invalid(format!("lpcm with {} bits per channel", a.const_bits_per_channel)))?; p.float = a.lpcm_flags & 1 != 0; p.big_endian = a.lpcm_flags & 2 != 0; p.signed = a.lpcm_flags & 4 != 0 || p.float; @@ -1113,7 +1117,7 @@ fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig { } _ => {} } - p + Ok(p) } fn parse_btrt(p: &[u8]) -> Result { @@ -1651,7 +1655,7 @@ mod tests { bit_depth: 10, chroma_subsampling: 1, full_range: true, - color_primaries: 9, + colour_primaries: 9, transfer_characteristics: 16, matrix_coefficients: 9, codec_init: vec![], @@ -1733,8 +1737,19 @@ mod tests { assert_eq!(PcmConfig { bits: 16, channels: u32::MAX, ..Default::default() }.bytes_per_frame(), u32::MAX); let ok = parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(2, 16), FourCc(*b"soun"), true).unwrap(); assert!(matches!(ok.codec, CodecConfig::Pcm(p) if p.bytes_per_frame() == 4)); - let hostile = parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(u32::MAX, 16), FourCc(*b"soun"), true).unwrap(); - assert!(matches!(hostile.codec, CodecConfig::Pcm(p) if p.bytes_per_frame() == u32::MAX)); + } + + #[test] + fn qt_v2_pcm_rejects_channels_and_bits_past_16_bits() { + let entry = |channels, bits| parse_sample_entry(FourCc(*b"lpcm"), &qt_v2_lpcm(channels, bits), FourCc(*b"soun"), true); + assert!(matches!(entry(u32::MAX, 16), Err(Error::Invalid(_)))); + assert!(matches!(entry(65_536, 16), Err(Error::Invalid(_)))); + assert!(entry(65_535, 16).is_ok()); + // 0x0001_0010 would read as 16-bit PCM, and 0x0001_0000 would fall back to the sample size. + assert!(matches!(entry(2, 0x0001_0010), Err(Error::Invalid(_)))); + assert!(matches!(entry(2, 0x0001_0000), Err(Error::Invalid(_)))); + let ok = entry(2, 24).unwrap(); + assert!(matches!(ok.codec, CodecConfig::Pcm(p) if p.bits == 24)); } #[test] diff --git a/crates/media/src/isobmff/demux.rs b/crates/media/src/isobmff/demux.rs index 323eac35..f3c0d78f 100644 --- a/crates/media/src/isobmff/demux.rs +++ b/crates/media/src/isobmff/demux.rs @@ -8,12 +8,33 @@ use std::collections::HashMap; /// Hard cap on the sample count of the whole file, all tracks and fragments (after PCM chunk merging). const MAX_SAMPLES: usize = 1 << 26; +/// Bytes of file each sample needs beyond the first `MIN_SAMPLE_ALLOWANCE` samples. A [`Sample`] +/// takes 40 bytes of memory and a constant-size `stsz` or a `trun` without per-sample fields +/// describes one in a byte or none, so without this a file could make the sample tables about +/// 40 times its own size. A table-backed sample has a 4-byte `stsz` or `trun` entry. +const FILE_BYTES_PER_SAMPLE: u64 = 4; +/// Samples any file may hold, however short: 40 MiB of sample tables. +const MIN_SAMPLE_ALLOWANCE: usize = 1 << 20; /// Hard cap on the size of an in-memory `moov`/`moof` box. const MAX_HEADER_BOX: u64 = 1 << 30; +/// Most text all metadata entries may hold together; entries past it are dropped. One `keys` +/// name can be named by many `ilst` items, each a copy, so the file's size does not bound this. +const MAX_METADATA_BYTES: usize = 4 << 20; +/// What an entry costs beyond its text, so that empty entries are bounded too. +const METADATA_ENTRY_COST: usize = 64; + +/// Largest sample built from a PCM chunk; larger chunks are split. Writers that put a whole +/// recording in one chunk would otherwise give samples past `u32::MAX` bytes. +const PCM_MAX_SAMPLE_BYTES: u64 = 1 << 24; + +/// Frames in one piece of a split PCM chunk: whole units, at most `PCM_MAX_SAMPLE_BYTES`. +fn pcm_piece_frames(unit: u64, unit_bytes: u64) -> u64 { + (PCM_MAX_SAMPLE_BYTES / unit_bytes.max(1)).max(1) * unit +} /// What is left of the file-wide limits while tracks and fragments are parsed. struct SampleBudget { - /// Samples (`MAX_SAMPLES` minus those already built). + /// Samples (what the file's length allows, at most `MAX_SAMPLES`, minus those already built). samples: usize, /// Bytes of file for samples whose count no table entry backs (constant-size `stsz`, `trun` /// without per-sample fields). Each such sample claims at least one byte, so a file can't @@ -22,6 +43,11 @@ struct SampleBudget { } impl SampleBudget { + fn new(file_len: u64) -> Self { + let by_len = usize::try_from(file_len / FILE_BYTES_PER_SAMPLE).unwrap_or(usize::MAX); + Self { samples: by_len.clamp(MIN_SAMPLE_ALLOWANCE, MAX_SAMPLES), implied_bytes: file_len } + } + fn take(&mut self, n: usize) -> Result<()> { self.samples = self.samples.checked_sub(n).ok_or(Error::TooLarge("sample count"))?; Ok(()) @@ -305,6 +331,7 @@ pub fn open(src: S) -> Result { } let mut trex: HashMap = HashMap::new(); let mut trak_payloads = Vec::new(); + let mut meta_left = MAX_METADATA_BYTES; for b in boxes(&moov) { let b = b?; match &b.kind.0 { @@ -334,12 +361,12 @@ pub fn open(src: S) -> Result { } } } - b"udta" => parse_udta(b.payload, &mut file.metadata), - b"meta" => parse_meta(b.payload, &mut file.metadata), + b"udta" => parse_udta(b.payload, &mut file.metadata, &mut meta_left), + b"meta" => parse_meta(b.payload, &mut file.metadata, &mut meta_left), _ => {} } } - let mut budget = SampleBudget { samples: MAX_SAMPLES, implied_bytes: len }; + let mut budget = SampleBudget::new(len); for p in trak_payloads { let t = parse_trak(p, &file, len, &mut budget)?; file.tracks.push(t); @@ -347,8 +374,13 @@ pub fn open(src: S) -> Result { // Movie fragments. let mut next_dts: Vec = file.tracks.iter().map(|t| t.samples.last().map(|s| s.dts.saturating_add(i64::from(s.duration))).unwrap_or(0)).collect(); + // A duplicate track ID resolves to its first track. + let mut track_index = HashMap::new(); + for (i, t) in file.tracks.iter().enumerate() { + track_index.entry(t.id).or_insert(i); + } for (moof_off, moof) in &moofs { - parse_moof(*moof_off, moof, &mut file.tracks, &trex, &mut next_dts, &mut budget)?; + parse_moof(*moof_off, moof, &mut file.tracks, &track_index, &trex, &mut next_dts, &mut budget)?; } for t in &mut file.tracks { if file.fragmented && !moofs.is_empty() { @@ -719,15 +751,16 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &m if let Some(p) = pcm { t.pcm_chunked = true; let a = t.audio().cloned().unwrap_or_default(); - let frame_bytes = |frames: u64| -> u64 { - if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 { - frames / a.samples_per_packet as u64 * a.bytes_per_frame as u64 - } else if const_size > 1 { - frames * const_size as u64 - } else { - frames * p.bytes_per_frame() as u64 - } + // Frames come in units of `unit` frames and `unit_bytes` bytes (QuickTime v1 packets). + let (unit, unit_bytes) = if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 { + (u64::from(a.samples_per_packet), u64::from(a.bytes_per_frame)) + } else if const_size > 1 { + (1, u64::from(const_size)) + } else { + (1, u64::from(p.bytes_per_frame())) }; + let frame_bytes = |frames: u64| -> u64 { (frames / unit).saturating_mul(unit_bytes) }; + let piece_frames = pcm_piece_frames(unit, unit_bytes); if chunks.len() > budget.samples { return Err(Error::TooLarge("chunk count")); } @@ -740,18 +773,35 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &m if spc == 0 { continue; } - let dur = stts_c.take(spc as u64); - let size = frame_bytes(spc as u64).min(u32::MAX as u64) as u32; - samples.push(Sample { - offset: off, - size, - dts, - pts: dts, - duration: dur.min(u32::MAX as u64) as u32, - is_sync: true, - description_index: stsc[stsc_i].2, - }); - dts = dts.saturating_add(dur.min(i64::MAX as u64) as i64); + // A chunk past PCM_MAX_SAMPLE_BYTES becomes several samples. + let mut left = spc as u64; + let mut pos = off; + while left > 0 { + if samples.len() >= budget.samples { + return Err(Error::TooLarge("sample count")); + } + let split = frame_bytes(left) > PCM_MAX_SAMPLE_BYTES; + let frames = if split { piece_frames } else { left }; + let dur = stts_c.take(frames); + let size = frame_bytes(frames); + if split { + // Each piece is at least half of PCM_MAX_SAMPLE_BYTES, so the file's size + // bounds how many a hostile chunk size can make. + budget.implied_bytes = budget.implied_bytes.checked_sub(size).ok_or(Error::TooLarge("PCM chunk size"))?; + } + samples.push(Sample { + offset: pos, + size: size.min(u32::MAX as u64) as u32, + dts, + pts: dts, + duration: dur.min(u32::MAX as u64) as u32, + is_sync: true, + description_index: stsc[stsc_i].2, + }); + dts = dts.saturating_add(dur.min(i64::MAX as u64) as i64); + pos = pos.saturating_add(size); + left -= frames; + } sidx += spc; if sidx >= sample_count { break; @@ -764,7 +814,20 @@ fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64, budget: &m if sample_count > budget.samples { return Err(Error::TooLarge("sample count")); } - samples.reserve(sample_count); + // Reserve only what the chunk map can place. A declared count far past it would keep + // capacity the budget below never charges, track after track. + let mut mapped = 0usize; + let mut map_i = 0usize; + for ci in 0..chunks.len() { + while map_i + 1 < stsc.len() && stsc[map_i + 1].0 as usize <= ci + 1 { + map_i += 1; + } + mapped = mapped.saturating_add(stsc.get(map_i).map_or(0, |e| e.1 as usize)); + if mapped >= sample_count { + break; + } + } + samples.try_reserve(sample_count.min(mapped)).map_err(|_| Error::TooLarge("sample count"))?; let mut stss_i = 0usize; 'chunks: for (ci, &off) in chunks.iter().enumerate() { while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { @@ -807,6 +870,7 @@ fn parse_moof( moof_off: u64, moof: &[u8], tracks: &mut [Track], + track_index: &HashMap, trex: &HashMap, next_dts: &mut [i64], budget: &mut SampleBudget, @@ -834,7 +898,7 @@ fn parse_moof( let mut c = Cur::labeled(tfhd, "tfhd"); let (_, fl) = c.full_header()?; let id = c.u32()?; - let Some(ti) = tracks.iter().position(|t| t.id == id) else { continue }; + let Some(&ti) = track_index.get(&id) else { continue }; let d = trex.get(&id).copied().unwrap_or_default(); let base = if fl & 1 != 0 { c.u64()? @@ -903,20 +967,42 @@ fn parse_moof( Ok(()) } -fn parse_udta(p: &[u8], md: &mut Metadata) { +/// Takes an entry of `key_len` and `value_len` bytes from `left`; false once it would not fit. +fn charge_metadata(left: &mut usize, key_len: usize, value_len: usize) -> bool { + let cost = key_len.saturating_add(value_len).saturating_add(METADATA_ENTRY_COST); + match left.checked_sub(cost) { + Some(rest) => { + *left = rest; + true + } + None => false, + } +} + +/// Length of `String::from_utf8_lossy(bytes)` without building it: each invalid sequence +/// becomes one three-byte U+FFFD. +fn lossy_len(bytes: &[u8]) -> usize { + bytes.utf8_chunks().map(|c| c.valid().len() + if c.invalid().is_empty() { 0 } else { 3 }).sum() +} + +fn parse_udta(p: &[u8], md: &mut Metadata, left: &mut usize) { for b in boxes(p).map_while(|b| b.ok()) { if b.kind == b"meta" { - parse_meta(b.payload, md); + parse_meta(b.payload, md, left); } else if b.kind.0[0] == 0xA9 && b.payload.len() >= 4 { let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - if let Some(s) = b.payload.get(4..4 + n) { - md.entries.push((b.kind.to_string_lossy(), String::from_utf8_lossy(s).into_owned())); + // Charged at the stored text's length, before the value is converted. + let key = b.kind.to_string_lossy(); + if let Some(s) = b.payload.get(4..4 + n) + && charge_metadata(left, key.len(), lossy_len(s)) + { + md.entries.push((key, String::from_utf8_lossy(s).into_owned())); } } } } -fn parse_meta(p: &[u8], md: &mut Metadata) { +fn parse_meta(p: &[u8], md: &mut Metadata, left: &mut usize) { // QuickTime `meta` is a plain box; ISO `meta` is a full box. let body = if p.len() >= 8 && &p[4..8] == b"hdlr" { p } else { p.get(4..).unwrap_or(&[]) }; let mut keys: Vec = Vec::new(); @@ -936,13 +1022,20 @@ fn parse_meta(p: &[u8], md: &mut Metadata) { let Some(ilst) = find(body, b"ilst") else { return }; for item in boxes(ilst).map_while(|b| b.ok()) { let idx = item.kind.as_u32() as usize; - let key = if !keys.is_empty() && idx >= 1 && idx <= keys.len() { keys[idx - 1].clone() } else { item.kind.to_string_lossy() }; + let named = if idx >= 1 { keys.get(idx - 1) } else { None }; if let Some(d) = find(item.payload, b"data") && d.len() >= 8 { let ty = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) & 0xFF_FFFF; + // Charged before the key is copied (every item naming one long key is another copy) + // and before the value is converted, at the lengths they will be stored at. if ty == 1 || ty == 0 { - md.entries.push((key, String::from_utf8_lossy(&d[8..]).into_owned())); + let fallback = if named.is_none() { Some(item.kind.to_string_lossy()) } else { None }; + let key_len = named.or(fallback.as_ref()).map_or(0, String::len); + if charge_metadata(left, key_len, lossy_len(&d[8..])) { + let key = named.cloned().or(fallback).unwrap_or_default(); + md.entries.push((key, String::from_utf8_lossy(&d[8..]).into_owned())); + } } } } @@ -1002,7 +1095,7 @@ mod tests { b.end(il); b.end(meta); let mut md = Metadata::default(); - parse_udta(&b.buf, &mut md); + parse_udta(&b.buf, &mut md, &mut { MAX_METADATA_BYTES }); assert_eq!(md.title(), Some("hi")); assert_eq!(md.get("©ART"), Some("me")); } @@ -1219,6 +1312,66 @@ mod tests { assert_eq!(open(d.as_slice()).unwrap().tracks[0].samples.len(), 4); } + #[test] + fn one_long_key_named_by_many_items_stays_within_the_metadata_budget() { + let key = vec![b'k'; 64 * 1024]; + let mut b = BoxBuf::new(); + b.leaf(b"hdlr", &[0; 24]); + let keys = b.start_full(b"keys", 0, 0); + b.u32(1); + b.u32(8 + key.len() as u32); + b.bytes(b"mdta"); + b.bytes(&key); + b.end(keys); + let il = b.start(b"ilst"); + for _ in 0..1000 { + let it = b.start(&1u32.to_be_bytes()); + let d = b.start(b"data"); + b.u32(1); + b.u32(0); + b.bytes(b"v"); + b.end(d); + b.end(it); + } + b.end(il); + let mut payload = vec![0; 4]; + payload.extend_from_slice(&b.buf); + let mut md = Metadata::default(); + parse_meta(&payload, &mut md, &mut { MAX_METADATA_BYTES }); + assert!(!md.entries.is_empty() && md.entries.len() < 1000, "{}", md.entries.len()); + assert!(md.entries.iter().map(|(k, v)| k.len() + v.len()).sum::() <= MAX_METADATA_BYTES); + assert_eq!(md.entries[0].0.len(), key.len()); + } + + #[test] + fn metadata_is_charged_at_its_stored_length() { + assert_eq!(lossy_len(b"plain"), 5); + for bytes in [&b"a\xFFb"[..], b"\xFF\xFE\xFD", b"\xE2\x82", "\u{a9}nam".as_bytes()] { + assert_eq!(lossy_len(bytes), String::from_utf8_lossy(bytes).len(), "{bytes:?}"); + } + // Invalid UTF-8 triples in size when converted; the budget sees the converted size. + let mut b = BoxBuf::new(); + let m = b.start(&[0xA9, b'n', b'a', b'm']); + b.u16(1000); + b.u16(0); + b.bytes(&[0xFF; 1000]); + b.end(m); + let mut md = Metadata::default(); + let mut left = 3000 + METADATA_ENTRY_COST + 5; + parse_udta(&b.buf, &mut md, &mut left); + assert_eq!(md.entries.len(), 1); + assert_eq!(md.entries[0].0.len() + md.entries[0].1.len(), 3000 + 5 - left); + } + + #[test] + fn a_declared_sample_count_past_the_chunk_map_reserves_only_what_it_maps() { + // stsz claims u32::MAX samples; the two chunks place four. + let d = handmade(Some((1, u32::MAX))); + let f = open(d.as_slice()).unwrap(); + assert_eq!(f.tracks[0].samples.len(), 4); + assert!(f.tracks[0].samples.capacity() < 64, "{}", f.tracks[0].samples.capacity()); + } + #[test] fn absurd_counts_are_bounded() { // Constant-size stsz claiming 4 billion samples is clamped to what fits in the file. @@ -1236,4 +1389,57 @@ mod tests { d[p - 4..p].copy_from_slice(&0x7FFF_FFFFu32.to_be_bytes()); assert!(open(d.as_slice()).is_err()); } + /// `data` followed by zeros up to `len` bytes. + struct Padded(Vec, u64); + + impl ByteSource for Padded { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + buf.fill(0); + let start = (offset as usize).min(self.0.len()); + let end = (start + buf.len()).min(self.0.len()); + buf[..end - start].copy_from_slice(&self.0[start..end]); + Ok(()) + } + } + + #[test] + fn samples_scale_with_the_file_length() { + assert_eq!(SampleBudget::new(1000).samples, MIN_SAMPLE_ALLOWANCE); + assert_eq!(SampleBudget::new(64 << 20).samples, 16 << 20); + assert_eq!(SampleBudget::new(u64::MAX).samples, MAX_SAMPLES); + // A constant-size stsz of one-byte samples claims as many as a 64 MiB file has bytes. + let d = handmade(Some((1, u32::MAX))); + assert!(matches!(open(Padded(d, 64 << 20)), Err(Error::TooLarge(_)))); + } + + #[test] + fn a_pcm_chunk_past_4_gib_reads_as_bounded_samples() { + use crate::isobmff::{Brand, Mp4Writer, PcmConfig, TrackConfig, WriteSample, WriterOptions}; + let pcm = PcmConfig { bits: 8, float: false, big_endian: false, signed: false, channels: 1, sample_rate: 48000.0 }; + let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap(); + let t = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap(); + w.write_sample(t, WriteSample { data: &[0; 4], duration: 0, composition_offset: 0, is_sync: true }).unwrap(); + let mut d = w.finish_faststart().unwrap().into_inner(); + // One chunk of u32::MAX one-byte frames, as a writer without chunk limits makes for a + // 4 GiB recording. + let frames = u32::MAX.to_be_bytes(); + for (kind, at) in [(b"stts", 12), (b"stsz", 12), (b"stsc", 16)] { + let p = d.windows(4).position(|w| w == kind).unwrap(); + d[p + at..p + at + 4].copy_from_slice(&frames); + } + // In a file too small for it, the claim is clamped to the file. + let f = open(d.as_slice()).unwrap(); + assert!(f.tracks[0].samples.iter().map(|s| u64::from(s.size)).sum::() <= d.len() as u64); + let len = d.len() as u64 + u64::from(u32::MAX); + let f = open(Padded(d, len)).unwrap(); + let s = &f.tracks[0].samples; + assert_eq!(s.len(), 256); + assert!(s.iter().all(|s| u64::from(s.size) <= PCM_MAX_SAMPLE_BYTES)); + assert_eq!(s.iter().map(|s| u64::from(s.size)).sum::(), u64::from(u32::MAX)); + assert_eq!(s.iter().map(|s| u64::from(s.duration)).sum::(), u64::from(u32::MAX)); + assert!(s.windows(2).all(|w| w[1].offset == w[0].offset + u64::from(w[0].size) && w[1].dts == w[0].dts + i64::from(w[0].duration))); + } } diff --git a/crates/media/src/isobmff/mux.rs b/crates/media/src/isobmff/mux.rs index 73774791..8f50d1f1 100644 --- a/crates/media/src/isobmff/mux.rs +++ b/crates/media/src/isobmff/mux.rs @@ -85,6 +85,18 @@ fn handler_of(e: &SampleEntry) -> Handler { } } +/// The `CodecConfig` variant, and the fourcc of variants that cover several codecs. +fn same_codec(a: &CodecConfig, b: &CodecConfig) -> bool { + use CodecConfig::*; + match (a, b) { + (ProRes { fourcc: x }, ProRes { fourcc: y }) + | (Jpeg { fourcc: x }, Jpeg { fourcc: y }) + | (Dnx { fourcc: x }, Dnx { fourcc: y }) + | (Unknown { fourcc: x, .. }, Unknown { fourcc: y, .. }) => x == y, + _ => std::mem::discriminant(a) == std::mem::discriminant(b), + } +} + /// Accumulated sample tables for one track. #[derive(Clone, Debug, Default)] struct Tables { @@ -109,16 +121,51 @@ impl Tables { fn new() -> Self { Tables { all_sync: true, ..Default::default() } } - fn push_run(runs: &mut Vec<(u32, T)>, count: u32, v: T) { + fn push_run(runs: &mut Vec<(u32, T)>, mut count: u64, v: T) { if let Some(last) = runs.last_mut() && last.1 == v - && last.0.checked_add(count).is_some() { - last.0 += count; - return; + let n = count.min(u64::from(u32::MAX - last.0)); + last.0 += n as u32; + count -= n; + } + while count > 0 { + let n = count.min(u64::from(u32::MAX)); + runs.push((n as u32, v)); + count -= n; } - runs.push((count, v)); } + + /// Records `count` samples written at `offset`, `sample_bytes` each, extending the last + /// chunk when the write follows it. A chunk holds at most `max` samples. + fn push_chunks(&mut self, mut offset: u64, mut count: u64, sample_bytes: u64, contiguous: bool, max: u32) { + if contiguous && let Some(c) = self.chunks.last_mut() { + let n = count.min(u64::from(max.saturating_sub(c.1))); + c.1 += n as u32; + count -= n; + offset += n * sample_bytes; + } + while count > 0 { + let n = count.min(u64::from(max)); + self.chunks.push((offset, n as u32)); + count -= n; + offset += n * sample_bytes; + } + } +} + +/// `stsz` counts samples (PCM: frames) in 32 bits. +fn too_many_samples() -> Error { + Error::Mux("more than 2^32 - 1 samples in one track".into()) +} + +/// PCM chunk size limit, ffmpeg's: readers that take each PCM chunk as one sample (this crate's +/// demuxer among them) get samples of bounded size. +const PCM_CHUNK_BYTES: u64 = 1 << 20; + +/// Frames per PCM chunk: at most [`PCM_CHUNK_BYTES`], at least one frame. +fn pcm_chunk_frames(frame_bytes: u32) -> u32 { + (PCM_CHUNK_BYTES / u64::from(frame_bytes.max(1))).max(1) as u32 } struct WTrack { @@ -420,11 +467,12 @@ fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, f b.u32(1); } b.end(m); + let sample_count = u32::try_from(tb.sample_count).map_err(|_| too_many_samples())?; let m = b.start_full(b"stsz", 0, 0); match tb.const_size { Some(s) => { b.u32(s); - b.u32(tb.sample_count.min(u32::MAX as u64) as u32); + b.u32(sample_count); } None => { let first = tb.sizes.first().copied().unwrap_or(0); @@ -432,10 +480,10 @@ fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, f // all-empty samples must still be written as a table. if first != 0 && !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) { b.u32(first); - b.u32(tb.sizes.len() as u32); + b.u32(sample_count); } else { b.u32(0); - b.u32(tb.sizes.len() as u32); + b.u32(sample_count); for &s in &tb.sizes { b.u32(s); } @@ -543,11 +591,14 @@ impl Mp4Writer { /// Replace a track's sample entry before the file is finished (the `moov` is written last), for /// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`. - /// The new entry must keep the media type and, for PCM, the frame size. + /// The new entry must keep the codec and, for PCM, the frame size. pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> { let t = self.tracks.get_mut(track).ok_or(Error::NoSuchTrack(track))?; - if handler_of(&entry) != t.handler || validate_track(&entry, t.cfg.timescale)? != t.pcm_frame_bytes { - return Err(Error::Mux("replacement sample entry changes the track's media type or PCM layout".into())); + if handler_of(&entry) != t.handler + || !same_codec(&entry.codec, &t.cfg.entry.codec) + || validate_track(&entry, t.cfg.timescale)? != t.pcm_frame_bytes + { + return Err(Error::Mux("replacement sample entry changes the track's codec or PCM layout".into())); } t.cfg.entry = entry; Ok(()) @@ -589,7 +640,7 @@ impl Mp4Writer { if !s.data.len().is_multiple_of(fb as usize) { return Err(Error::Mux("PCM data is not a whole number of frames".into())); } - ((s.data.len() / fb as usize) as u32, 1u32) + (s.data.len() as u64 / u64::from(fb), 1u32) } None => { if s.data.len() > u32::MAX as usize { @@ -601,6 +652,9 @@ impl Mp4Writer { if count == 0 { return Ok(()); } + if tr.t.sample_count + count > u64::from(u32::MAX) { + return Err(too_many_samples()); + } self.w.write_all(s.data)?; let offset = self.pos; self.pos += s.data.len() as u64; @@ -618,13 +672,13 @@ impl Mp4Writer { t.all_sync = false; } } - t.sample_count += count as u64; + t.sample_count += count; let ct = if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset as i64 }; t.comp_end = t.comp_end.max((t.media_duration as i64 + ct + count as i64 * dur as i64).max(0) as u64); - t.media_duration += count as u64 * dur as u64; - match t.chunks.last_mut() { - Some(c) if contiguous => c.1 += count, - _ => t.chunks.push((offset, count)), + t.media_duration += count * dur as u64; + match tr.pcm_frame_bytes { + Some(fb) => t.push_chunks(offset, count, u64::from(fb), contiguous, pcm_chunk_frames(fb)), + None => t.push_chunks(offset, count, s.data.len() as u64, contiguous, u32::MAX), } Ok(()) } @@ -960,6 +1014,9 @@ mod tests { assert!(w.set_sample_entry(a, SampleEntry::pcm(PcmConfig { big_endian: true, ..pcm })).is_ok()); assert!(w.set_sample_entry(a, SampleEntry::pcm(PcmConfig { bits: 24, ..pcm })).is_err()); assert!(w.set_sample_entry(a, avc_entry()).is_err()); + let v = w.add_track(TrackConfig::new(avc_entry(), 90_000)).unwrap(); + assert!(w.set_sample_entry(v, avc_entry()).is_ok()); + assert!(w.set_sample_entry(v, SampleEntry::jpeg(64, 64)).is_err()); assert!(w.set_sample_entry(f, SampleEntry::flac(&[1; 34])).is_ok()); assert!(w.set_sample_entry(f, SampleEntry::pcm(pcm)).is_err()); assert!(matches!(w.set_sample_entry(5, avc_entry()), Err(Error::NoSuchTrack(5)))); diff --git a/crates/media/src/matroska/demux.rs b/crates/media/src/matroska/demux.rs index 2c95288f..15ca9dbd 100644 --- a/crates/media/src/matroska/demux.rs +++ b/crates/media/src/matroska/demux.rs @@ -1,6 +1,6 @@ //! Matroska / WebM demuxer: EBML header, Segment walk, cluster scanning, packet reading, seeking. -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, VecDeque}; use crate::matroska::ebml::{self, Children, Lacing, Size, parse_lacing, read_id, read_size}; use crate::matroska::error::{Error, Result}; @@ -12,6 +12,42 @@ use crate::matroska::track::{Sample, Track, TrackKind, gcd}; const WIN: usize = 64 * 1024; /// Upper bound for level-1 metadata elements read into memory (Info, Tracks, Cues, Tags…). const MAX_META: u64 = 256 << 20; +/// Cap on the frames indexed from one file, all tracks together. +const MAX_FRAMES: usize = 1 << 26; +/// Bytes of file each indexed frame needs beyond the first `MIN_FRAME_ALLOWANCE` frames. An +/// indexed frame takes about 76 bytes of memory (its [`Sample`] and keyframe entry) and fixed +/// lacing can describe one in no bytes at all, so without this a file could make the index +/// about 76 times its own size. Real frames carry a block header or lace size and their data, +/// well over 8 bytes each. +const FILE_BYTES_PER_FRAME: u64 = 8; +/// Frames any file may index, however short: about 80 MiB of index. +const MIN_FRAME_ALLOWANCE: usize = 1 << 20; +/// Cap on the TrackEntries of a file. Each kept entry needs only 5 bytes of file but becomes a +/// [`Track`] of several hundred bytes. +pub(super) const MAX_TRACKS: usize = 1 << 16; + +/// What is left of the file-wide limits while clusters are scanned. +struct FrameBudget { + /// Frames (what the file's length allows, at most `MAX_FRAMES`, minus those already indexed). + frames: usize, + /// Bytes of file not yet claimed by a frame. Each frame claims its stored size and at least + /// one byte, because fixed-size lacing describes up to 256 empty frames in a 4-byte block. + bytes: u64, +} + +impl FrameBudget { + fn new(file_len: u64) -> Self { + let by_len = usize::try_from(file_len / FILE_BYTES_PER_FRAME).unwrap_or(usize::MAX); + Self { frames: by_len.clamp(MIN_FRAME_ALLOWANCE, MAX_FRAMES), bytes: file_len } + } + + fn take(&mut self, frames: &[(u64, u32)]) -> Result<()> { + self.frames = self.frames.checked_sub(frames.len()).ok_or(Error::TooLarge("frame count"))?; + let bytes = frames.iter().map(|&(_, n)| u64::from(n.max(1))).sum::(); + self.bytes = self.bytes.checked_sub(bytes).ok_or(Error::TooLarge("frame count"))?; + Ok(()) + } +} /// Options for [`open_with`] / [`Demuxer::with_options`]. #[derive(Clone, Debug)] @@ -324,8 +360,15 @@ struct FrameRef { addition: Option<(u64, u32)>, } -fn track_lookup(tracks: &[Track]) -> impl Fn(u64) -> Option + '_ { - move |n| tracks.iter().position(|t| t.number == n) +/// Track number to track index. A duplicate number resolves to its first track. +type TrackIndex = HashMap; + +fn track_index(tracks: &[Track]) -> TrackIndex { + let mut m = HashMap::with_capacity(tracks.len()); + for (i, t) in tracks.iter().enumerate() { + m.entry(t.number).or_insert(i); + } + m } /// A parsed block header: (track index, relative ts, flags, frames as (offset, size)). @@ -333,12 +376,7 @@ type BlockHeader = (usize, i16, u8, Vec<(u64, u32)>); /// Parse the Block/SimpleBlock header at `data_start` (`len` bytes). /// Returns `None` for unknown tracks / damage. -fn parse_block( - io: &mut Io<'_, S>, - data_start: u64, - len: u64, - lookup: &dyn Fn(u64) -> Option, -) -> Result> { +fn parse_block(io: &mut Io<'_, S>, data_start: u64, len: u64, lookup: &TrackIndex) -> Result> { let head = io.peek(data_start, 16.min(len as usize))?; let Some((tn, tl, _)) = ebml::read_vint(head) else { return Ok(None) }; if head.len() < tl + 3 { @@ -346,7 +384,7 @@ fn parse_block( } let rel = i16::from_be_bytes([head[tl], head[tl + 1]]); let flags = head[tl + 2]; - let Some(track) = lookup(tn) else { return Ok(None) }; + let Some(&track) = lookup.get(&tn) else { return Ok(None) }; let hdr = (tl + 3) as u64; let lacing = Lacing::from_flags(flags); let frames = if lacing == Lacing::None { @@ -402,13 +440,7 @@ fn cluster_timestamp(raw: u64) -> i64 { } /// Parse one SimpleBlock or BlockGroup element. -fn parse_block_elem( - io: &mut Io<'_, S>, - h: &Hdr, - end: u64, - cluster_ts: i64, - lookup: &dyn Fn(u64) -> Option, -) -> Result> { +fn parse_block_elem(io: &mut Io<'_, S>, h: &Hdr, end: u64, cluster_ts: i64, lookup: &TrackIndex) -> Result> { let ds = h.data_start(); match h.id { SIMPLE_BLOCK => { @@ -514,10 +546,10 @@ fn scan_cluster( io: &mut Io<'_, S>, h: &Hdr, limit: u64, - tracks: &[Track], + lookup: &TrackIndex, + budget: &mut FrameBudget, warnings: &mut Vec, ) -> Result<(ClusterInfo, Vec)> { - let lookup = track_lookup(tracks); let ds = h.data_start(); let known_end = match h.size { Size::Known(_) => Some(h.end(limit)), @@ -549,7 +581,8 @@ fn scan_cluster( match c.id { TIMESTAMP => ts = cluster_timestamp(ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), SIMPLE_BLOCK | BLOCK_GROUP => { - if let Some(b) = parse_block_elem(io, &c, ce, ts, &lookup)? { + if let Some(b) = parse_block_elem(io, &c, ce, ts, lookup)? { + budget.take(&b.frames)?; blocks.push(b); } } @@ -637,7 +670,7 @@ fn open_impl(src: &S, opts: &OpenOptions, cache: &mut Ca if seg.overruns(len) { file.warnings.push("Segment extends past end of file (truncated)".into()); } - let mut w = Walker { opts, parsed: HashSet::new(), blocks_seen: false }; + let mut w = Walker { opts, parsed: HashMap::new(), meta_left: len, blocks_seen: false, lookup: None, budget: FrameBudget::new(len) }; w.walk(&mut io, &mut file)?; finish_tracks(&mut file); Ok(file) @@ -645,8 +678,15 @@ fn open_impl(src: &S, opts: &OpenOptions, cache: &mut Ca struct Walker<'o> { opts: &'o OpenOptions, - parsed: HashSet, + /// Start and end of each level-1 element read. + parsed: HashMap, + /// Bytes level-1 elements may still read or scan. Distinct elements do not overlap, so a + /// valid file stays within its length; SeekHead entries pointing into one another do not. + meta_left: u64, blocks_seen: bool, + /// Built at the first Cluster after Tracks is parsed. + lookup: Option, + budget: FrameBudget, } impl Walker<'_> { @@ -674,7 +714,8 @@ impl Walker<'_> { break; } } - let (ci, blocks) = scan_cluster(io, &h, seg_end, &file.tracks, &mut file.warnings)?; + let lookup = self.lookup.get_or_insert_with(|| track_index(&file.tracks)); + let (ci, blocks) = scan_cluster(io, &h, seg_end, lookup, &mut self.budget, &mut file.warnings)?; if self.opts.verify_crc && h.size != Size::Unknown { let data = io.read_vec(h.data_start(), ci.end - h.data_start())?; self.check_crc(file, h.pos, &data); @@ -720,7 +761,7 @@ impl Walker<'_> { continue; } let Some(abs) = file.segment_data_start.checked_add(e.position) else { continue }; - if self.parsed.contains(&abs) || abs >= file.segment_end { + if self.parsed.contains_key(&abs) || abs >= file.segment_end { continue; } match io.header(abs, file.segment_end)? { @@ -745,13 +786,24 @@ impl Walker<'_> { /// Parse a level-1 metadata element; returns its end. fn level1(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, h: &Hdr, limit: u64) -> Result { - let end = if h.size == Size::Unknown { io.unknown_end(h.data_start(), limit)? } else { h.end(limit) }; + if let Some(&end) = self.parsed.get(&h.pos) { + return Ok(end); + } + let end = match h.size { + Size::Unknown => io.unknown_end(h.data_start(), limit.min(h.data_start().saturating_add(self.meta_left)))?, + Size::Known(_) => h.end(limit), + }; + self.parsed.insert(h.pos, end); if h.overruns(limit) { file.warnings.push(format!("element 0x{:X} at {} truncated", h.id, h.pos)); } - if !self.parsed.insert(h.pos) { + let n = end - h.data_start(); + if n > self.meta_left || (h.size == Size::Unknown && n == self.meta_left && end < limit) { + self.meta_left = 0; + file.warnings.push(format!("element 0x{:X} at {} overlaps elements already read; skipped", h.id, h.pos)); return Ok(end); } + self.meta_left -= n; if h.id == ATTACHMENTS { self.attachments(io, file, h.data_start(), end)?; return Ok(end); @@ -769,7 +821,11 @@ impl Walker<'_> { SEEK_HEAD => file.seek_head.extend(parse_seekhead(&data)), INFO => file.info = parse_info(&data), TRACKS if file.tracks.is_empty() => { + if Children::new(&data).filter(|e| e.id == TRACK_ENTRY).count() > MAX_TRACKS { + return Err(Error::TooLarge("track count")); + } file.tracks = parse_tracks(&data, file.info.timestamp_scale); + self.lookup = None; } CUES => parse_cues(&data, &mut file.cues), CHAPTERS => parse_chapters(&data, &mut file.chapters), @@ -931,6 +987,7 @@ pub struct Demuxer { file: MkvFile, opts: OpenOptions, cache: Cache, + lookup: TrackIndex, pos: u64, cluster: Option, pending: VecDeque, @@ -962,7 +1019,8 @@ impl Demuxer { let mut cache = Cache::default(); let file = open_impl(&src, &opts, &mut cache)?; let pos = file.first_cluster.unwrap_or(file.segment_end); - Ok(Self { src, file, opts, cache, pos, cluster: None, pending: VecDeque::new(), skip_laces: None, done: false }) + let lookup = track_index(&file.tracks); + Ok(Self { src, file, opts, cache, lookup, pos, cluster: None, pending: VecDeque::new(), skip_laces: None, done: false }) } pub fn file(&self) -> &MkvFile { @@ -989,6 +1047,7 @@ impl Demuxer { } let opts = OpenOptions { index: true, verify_crc: self.opts.verify_crc }; self.file = open_impl(&self.src, &opts, &mut self.cache)?; + self.lookup = track_index(&self.file.tracks); Ok(()) } @@ -1076,7 +1135,8 @@ impl Demuxer { return Ok(None); } let mut warnings = Vec::new(); - let (ci, blocks) = scan_cluster(&mut io, &h, self.file.segment_end, &self.file.tracks, &mut warnings)?; + let mut budget = FrameBudget::new(io.len); + let (ci, blocks) = scan_cluster(&mut io, &h, self.file.segment_end, &self.lookup, &mut budget, &mut warnings)?; let t = &self.file.tracks[track]; let mut frames = Vec::new(); for b in blocks.iter().filter(|b| b.track == track && b.keyframe) { @@ -1139,7 +1199,7 @@ impl Demuxer { let seg_end = self.file.segment_end; let scale = self.file.info.timestamp_scale; let mut io = Io::new(&self.src, &mut self.cache); - let lookup = track_lookup(&self.file.tracks); + let lookup = &self.lookup; loop { if let Some(c) = self.cluster { let lim = c.end.unwrap_or(seg_end); @@ -1179,7 +1239,7 @@ impl Demuxer { self.cluster = Some(InCluster { ts: v, end: c.end }); } SIMPLE_BLOCK | BLOCK_GROUP => { - if let Some(b) = parse_block_elem(&mut io, &h, he, c.ts, &lookup)? { + if let Some(b) = parse_block_elem(&mut io, &h, he, c.ts, lookup)? { let skip = match self.skip_laces.take() { Some((off, n)) if off == h.pos => n, _ => 0, @@ -1236,3 +1296,20 @@ impl Iterator for Demuxer { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_indexed_scale_with_the_file_length() { + assert_eq!(FrameBudget::new(1000).frames, MIN_FRAME_ALLOWANCE); + // 64 MiB of file is enough bytes for 2^26 empty frames, but not enough index memory. + let mut b = FrameBudget::new(64 << 20); + assert_eq!(b.frames, 8 << 20); + let lace = [(0u64, 0u32); 256]; + let taken = (0..1 << 18).take_while(|_| b.take(&lace).is_ok()).count(); + assert_eq!(taken * lace.len(), 8 << 20); + assert_eq!(FrameBudget::new(u64::MAX).frames, MAX_FRAMES); + } +} diff --git a/crates/media/src/matroska/error.rs b/crates/media/src/matroska/error.rs index 718c5c50..e3157a3d 100644 --- a/crates/media/src/matroska/error.rs +++ b/crates/media/src/matroska/error.rs @@ -16,6 +16,8 @@ pub enum Error { NoSuchTrack(usize), /// A sample index was out of range. NoSuchSample(usize), + /// The file describes more than the demuxer's limits allow (frames, tracks). + TooLarge(&'static str), } impl fmt::Display for Error { @@ -27,6 +29,7 @@ impl fmt::Display for Error { Error::NotMatroska(s) => write!(f, "not a Matroska/WebM file: {s}"), Error::NoSuchTrack(i) => write!(f, "track index {i} out of range"), Error::NoSuchSample(i) => write!(f, "sample index {i} out of range"), + Error::TooLarge(s) => write!(f, "size limit exceeded: {s}"), } } } diff --git a/crates/media/src/matroska/ids.rs b/crates/media/src/matroska/ids.rs index 3c7eefd3..5998c1e2 100644 --- a/crates/media/src/matroska/ids.rs +++ b/crates/media/src/matroska/ids.rs @@ -107,10 +107,10 @@ pub const PIXEL_CROP_RIGHT: u32 = 0x54DD; pub const DISPLAY_WIDTH: u32 = 0x54B0; pub const DISPLAY_HEIGHT: u32 = 0x54BA; pub const DISPLAY_UNIT: u32 = 0x54B2; -/// Matroska `ColourSpace`. -pub const COLOR_SPACE: u32 = 0x2E_B524; -/// Matroska `Colour`. -pub const COLOR: u32 = 0x55B0; +/// `ColourSpace`; read into [`VideoInfo::color_space`](crate::matroska::VideoInfo::color_space). +pub const COLOUR_SPACE: u32 = 0x2E_B524; +/// `Colour`; read into [`Color`](crate::matroska::Color). +pub const COLOUR: u32 = 0x55B0; pub const PROJECTION: u32 = 0x7670; pub const PROJECTION_TYPE: u32 = 0x7671; pub const PROJECTION_POSE_YAW: u32 = 0x7673; diff --git a/crates/media/src/matroska/meta.rs b/crates/media/src/matroska/meta.rs index 3843c91f..8cf80248 100644 --- a/crates/media/src/matroska/meta.rs +++ b/crates/media/src/matroska/meta.rs @@ -8,6 +8,11 @@ use crate::matroska::track::{AudioInfo, Color, ContentEncoding, MasteringMetadat // A malformed Matroska file may contain arbitrarily deep ChapterAtom and SimpleTag chains. const MAX_META_DEPTH: usize = 64; +/// Longest header-stripping prefix accepted. It is copied in front of every frame, laced +/// zero-length frames included, so its length multiplies what a small file can make a reader +/// produce. Real files strip a few bytes of codec header; a longer one is treated as an +/// unsupported compression and its frames are not reconstructed. +const MAX_STRIPPED_HEADER: usize = 256; /// Largest accepted `TimestampScale` (about 4.3 s per tick). It keeps tick to nanosecond /// arithmetic on 64-bit timestamps within `i128`. const MAX_TIMESTAMP_SCALE: u64 = 1 << 32; @@ -403,8 +408,8 @@ fn parse_video(d: &[u8]) -> VideoInfo { FIELD_ORDER => v.field_order = Some(u), STEREO_MODE => v.stereo_mode = Some(u), ALPHA_MODE => v.alpha_mode = u, - COLOR_SPACE => v.color_space = e.data.try_into().ok(), - COLOR => v.color = Some(parse_color(e.data)), + COLOUR_SPACE => v.color_space = e.data.try_into().ok(), + COLOUR => v.color = Some(parse_color(e.data)), PROJECTION => { let mut p = Projection::default(); for x in Children::new(e.data) { @@ -466,7 +471,7 @@ fn parse_encodings(d: &[u8]) -> Vec { ContentEncoding::Encryption { scope } } else { let (algo, settings) = comp.unwrap_or((0, Vec::new())); - if algo == 3 { + if algo == 3 && settings.len() <= MAX_STRIPPED_HEADER { ContentEncoding::HeaderStripping { bytes: settings, scope } } else { ContentEncoding::Compression { algo, settings, scope } @@ -542,15 +547,19 @@ pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec { t.audio = Some(AudioInfo::default()); } // CodecPrivate may itself be header-stripped (scope bit 2 = private data) - for enc in &t.encodings { + // Prepends in list order, built in one pass: a hostile entry can list millions. + let mut prefix = Vec::new(); + for enc in t.encodings.iter().rev() { if let ContentEncoding::HeaderStripping { bytes, scope } = enc && scope & 2 != 0 { - let mut p = bytes.clone(); - p.extend_from_slice(&t.codec_private); - t.codec_private = p; + prefix.extend_from_slice(bytes); } } + if !prefix.is_empty() { + prefix.extend_from_slice(&t.codec_private); + t.codec_private = prefix; + } t.codec = map_codec(&t.codec_id, &t.codec_private, t.audio.as_ref()); out.push(t); } @@ -560,6 +569,56 @@ pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec { #[cfg(test)] mod metadata_depth_tests { use super::*; + use crate::matroska::mux::{el, el_uint}; + + fn header_stripping(bytes: &[u8]) -> Vec { + let mut comp = Vec::new(); + el_uint(&mut comp, CONTENT_COMP_ALGO, 3); + el(&mut comp, CONTENT_COMP_SETTINGS, bytes); + let mut enc = Vec::new(); + el(&mut enc, CONTENT_COMPRESSION, &comp); + let mut encs = Vec::new(); + el(&mut encs, CONTENT_ENCODING, &enc); + parse_encodings(&encs) + } + + #[test] + fn a_long_stripped_header_is_not_put_in_front_of_frames() { + let kept = header_stripping(&[7; MAX_STRIPPED_HEADER]); + assert!(matches!(&kept[..], [ContentEncoding::HeaderStripping { bytes, .. }] if bytes.len() == MAX_STRIPPED_HEADER)); + let refused = header_stripping(&[7; MAX_STRIPPED_HEADER + 1]); + assert!(matches!(&refused[..], [ContentEncoding::Compression { algo: 3, .. }]), "{refused:?}"); + } + + fn track_with_private(private: &[u8], strips: &[(&[u8], u64)]) -> Track { + let mut encs = Vec::new(); + for &(bytes, scope) in strips { + let mut comp = Vec::new(); + el_uint(&mut comp, CONTENT_COMP_ALGO, 3); + el(&mut comp, CONTENT_COMP_SETTINGS, bytes); + let mut enc = Vec::new(); + el_uint(&mut enc, CONTENT_ENCODING_SCOPE, scope); + el(&mut enc, CONTENT_COMPRESSION, &comp); + el(&mut encs, CONTENT_ENCODING, &enc); + } + let mut te = Vec::new(); + el_uint(&mut te, TRACK_NUMBER, 1); + el(&mut te, CODEC_ID, b"V_TEST"); + el(&mut te, CODEC_PRIVATE, private); + el(&mut te, CONTENT_ENCODINGS, &encs); + let mut tracks = Vec::new(); + el(&mut tracks, TRACK_ENTRY, &te); + let mut parsed = parse_tracks(&tracks, 1_000_000); + assert_eq!(parsed.len(), 1); + parsed.remove(0) + } + + #[test] + fn codec_private_stripped_headers_are_restored_in_reverse_order() { + let strips: [(&[u8], u64); 4] = [(b"first", 2), (b"", 2), (b"second", 2), (b"frame", 1)]; + assert_eq!(track_with_private(b"private", &strips).codec_private, b"secondfirstprivate"); + assert_eq!(track_with_private(b"private", &[(b"frame", 1)]).codec_private, b"private"); + } #[test] fn deeply_nested_chapters_stop_at_depth_limit() { diff --git a/crates/media/src/matroska/mux.rs b/crates/media/src/matroska/mux.rs index 2ba663a4..f481ceac 100644 --- a/crates/media/src/matroska/mux.rs +++ b/crates/media/src/matroska/mux.rs @@ -64,7 +64,8 @@ pub struct MuxOptions { /// Start a new cluster at the next video keyframe (or any frame without video) after this long. pub cluster_duration_ns: u64, pub lacing: LacingMode, - /// Maximum frames per laced block. + /// Maximum frames per laced block. Matroska allows 1 to 256; [`MkvWriter::new`] clamps + /// other values into that range. pub max_lace_frames: usize, pub title: Option, pub writing_app: String, @@ -136,16 +137,21 @@ pub struct MkvWriter { cluster_start_ns: i64, pending: Option, cues: Vec<(u64, u64, u64)>, // (time ticks, track number, cluster position) - cued_tracks_in_cluster: Vec, + /// The cluster number in which each track last had a cue. Starting a cluster bumps + /// `cluster_number` instead of clearing an entry for every declared track. + track_cued_in: Vec, + cluster_number: u64, end_ns: i64, has_video: bool, } impl MkvWriter { - pub fn new(mut w: W, tracks: Vec, opts: MuxOptions) -> Result { + pub fn new(mut w: W, tracks: Vec, mut opts: MuxOptions) -> Result { if tracks.is_empty() || opts.timestamp_scale == 0 { return Err(Error::Invalid("muxer needs at least one track and a non-zero timestamp scale".into())); } + // A block stores its frame count minus one in a byte. + opts.max_lace_frames = opts.max_lace_frames.clamp(1, 256); let mut out = Vec::new(); let mut h = Vec::new(); el_uint(&mut h, EBML_VERSION, 1); @@ -253,7 +259,8 @@ impl MkvWriter { cluster_start_ns: 0, pending: None, cues: Vec::new(), - cued_tracks_in_cluster: vec![false; n], + track_cued_in: vec![u64::MAX; n], + cluster_number: 0, end_ns: 0, has_video, }) @@ -294,8 +301,8 @@ impl MkvWriter { } let cts = self.cluster_ts.unwrap_or(0); // cue: video keyframes, or the first frame of each track per cluster when there is no video - if (is_video && keyframe) || (!self.has_video && !self.cued_tracks_in_cluster[track]) { - self.cued_tracks_in_cluster[track] = true; + if (is_video && keyframe) || (!self.has_video && self.track_cued_in[track] != self.cluster_number) { + self.track_cued_in[track] = self.cluster_number; let pos = self.w.stream_position()? - self.segment_data_start; if self.cues.last().is_none_or(|c| c.2 != pos || c.1 != track as u64 + 1) { self.cues.push((ts as u64, track as u64 + 1, pos)); @@ -329,7 +336,7 @@ impl MkvWriter { block.extend_from_slice(data); let mut g = Vec::new(); el(&mut g, BLOCK, &block); - el_uint(&mut g, BLOCK_DURATION, (d as i64 / scale) as u64); + el_uint(&mut g, BLOCK_DURATION, ((u128::from(d) + scale as u128 / 2) / scale as u128) as u64); if !keyframe { el(&mut g, REFERENCE_BLOCK, &[0xFF]); } @@ -399,7 +406,7 @@ impl MkvWriter { } self.cluster.clear(); self.cluster_ts = None; - self.cued_tracks_in_cluster.iter_mut().for_each(|c| *c = false); + self.cluster_number = self.cluster_number.wrapping_add(1); Ok(()) } @@ -568,4 +575,53 @@ mod tests { let got: Vec<_> = Demuxer::from_slice(&bytes).unwrap().map(|p| p.unwrap()).map(|p| (p.track, p.pts, p.lace)).collect(); assert_eq!(got, [(0, 0, 0), (0, 100, 0), (0, 110, 1), (0, 120, 2), (0, 200, 0), (1, 300, 0), (1, 310, 0)]); } + + #[test] + fn lace_size_is_clamped_to_256_frames() { + let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT"); + a.audio = Some((48000.0, 1, Some(16))); + a.default_duration_ns = Some(1_000_000); + for lacing in [LacingMode::Xiph, LacingMode::Ebml, LacingMode::FixedOrEbml] { + let opts = MuxOptions { lacing, max_lace_frames: 300, ..Default::default() }; + let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![a.clone()], opts).unwrap(); + for i in 0..300i64 { + m.write_frame(0, i * 1_000_000, true, &[i as u8; 4], None).unwrap(); + } + let bytes = m.finish().unwrap().into_inner(); + let d = Demuxer::from_slice(&bytes).unwrap(); + assert!(d.file().warnings.is_empty(), "{:?}", d.file().warnings); + let got: Vec<_> = d.map(|p| p.unwrap()).map(|p| (p.pts, p.lace, p.data)).collect(); + let want: Vec<_> = (0..300i64).map(|i| (i, (i % 256) as u16, vec![i as u8; 4])).collect(); + assert_eq!(got, want); + } + } + + #[test] + fn block_duration_rounds_to_the_nearest_tick() { + let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG"); + v.video_size = Some((16, 16)); + let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![v], MuxOptions::default()).unwrap(); + m.write_frame(0, 0, true, &[0; 4], Some(41_700_000)).unwrap(); + let bytes = m.finish().unwrap().into_inner(); + assert_eq!(Demuxer::from_slice(&bytes).unwrap().next_packet().unwrap().unwrap().duration, 42); + } + + #[test] + fn audio_only_files_cue_each_track_once_per_cluster() { + let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT"); + a.audio = Some((48000.0, 1, Some(16))); + let opts = MuxOptions { cluster_duration_ns: 1, lacing: LacingMode::None, ..Default::default() }; + let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![a.clone(), a], opts).unwrap(); + for i in 0..4i64 { + m.write_frame(0, i * 10, true, &[1; 4], None).unwrap(); + m.write_frame(1, i * 10, true, &[2; 4], None).unwrap(); + m.write_frame(0, i * 10 + 1, true, &[3; 4], None).unwrap(); + } + // Clusters hold [t0, t1] then [t0] at each step: one cue per track per cluster. + let mut seen: Vec<(u64, u64)> = m.cues.iter().map(|c| (c.1, c.2)).collect(); + assert_eq!(seen.len(), 12, "{:?}", m.cues); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), 12, "{:?}", m.cues); + } } diff --git a/crates/media/src/matroska/synthetic_tests.rs b/crates/media/src/matroska/synthetic_tests.rs index 65fa4dde..3ec0674f 100644 --- a/crates/media/src/matroska/synthetic_tests.rs +++ b/crates/media/src/matroska/synthetic_tests.rs @@ -2,11 +2,13 @@ //! header stripping, BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32. use crate::matroska::codec::Codec; +use crate::matroska::demux::MAX_TRACKS; use crate::matroska::ebml::{self, write_id, write_unknown_size}; +use crate::matroska::error::Error; use crate::matroska::ids::*; use crate::matroska::mux::{el, el_float, el_str, el_uint}; use crate::matroska::track::ContentEncoding; -use crate::matroska::{Demuxer, OpenOptions, Packet, open_with}; +use crate::matroska::{ByteSource, Demuxer, OpenOptions, Packet, open_with}; fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec { let mut b = vec![0x80 | track]; @@ -64,7 +66,7 @@ fn synthetic() -> Vec { el_uint(&mut vid, PIXEL_HEIGHT, 1080); el_uint(&mut vid, DISPLAY_WIDTH, 1920); el_uint(&mut vid, DISPLAY_HEIGHT, 1080); - el(&mut vid, COLOR, &col); + el(&mut vid, COLOUR, &col); // rectangular, a quarter turn clockwise (portrait phone video) let mut proj = Vec::new(); el_uint(&mut proj, PROJECTION_TYPE, 0); @@ -349,3 +351,95 @@ fn duration_of_a_block_at_the_largest_timestamp_saturates() { assert_eq!(f.duration_ns(), Some(u64::MAX)); } } + +#[test] +fn empty_laced_frames_exhaust_the_frame_budget() { + // Each 7-byte SimpleBlock is a fixed-size lace of 256 empty frames. + let mut c = Vec::new(); + for _ in 0..100 { + el(&mut c, SIMPLE_BLOCK, &block(1, 0, 0x04, &[0xFF])); + } + let r = open_with(&one_cluster(0, &c)[..], &OpenOptions::default()); + assert!(matches!(r, Err(Error::TooLarge(_))), "{r:?}"); + // Laces of one-byte frames are paid for by their bytes. + let mut c = Vec::new(); + for _ in 0..4 { + let mut data = vec![0xFF]; + data.extend_from_slice(&[0; 256]); + el(&mut c, SIMPLE_BLOCK, &block(1, 0, 0x04, &data)); + } + let f = open_with(&one_cluster(0, &c)[..], &OpenOptions::default()).unwrap(); + assert_eq!(f.tracks[0].samples.len(), 1024); +} + +#[test] +fn track_entries_past_the_cap_are_rejected() { + let file = |entries: usize| { + let mut f = Vec::new(); + let mut h = Vec::new(); + el_str(&mut h, DOC_TYPE, "matroska"); + el(&mut f, EBML, &h); + write_id(&mut f, SEGMENT); + write_unknown_size(&mut f, 8); + let mut tr = Vec::new(); + for _ in 0..entries { + el(&mut tr, TRACK_ENTRY, &[0xD7, 0x81, 0x02]); + } + el(&mut f, TRACKS, &tr); + f + }; + assert_eq!(open_with(&file(3)[..], &OpenOptions::default()).unwrap().tracks.len(), 3); + let r = open_with(&file(MAX_TRACKS + 1)[..], &OpenOptions::default()); + assert!(matches!(r, Err(Error::TooLarge(_))), "{r:?}"); +} + +/// Counts the bytes read from `data`. +struct Counting<'a>(&'a [u8], std::cell::Cell); + +impl ByteSource for Counting<'_> { + fn len(&self) -> u64 { + self.0.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.1.set(self.1.get() + buf.len() as u64); + self.0.read_at(offset, buf) + } +} + +#[test] +fn overlapping_seek_head_targets_are_read_once() { + // SeekHead entries point at nested Tags headers, each running to the end of the file. + let (n, pad) = (64u64, 128usize << 10); + let entry = |pos: u64| { + let mut s = Vec::new(); + el(&mut s, SEEK_ID, &TAGS.to_be_bytes()); + el(&mut s, SEEK_POSITION, &pos.to_be_bytes()); + let mut o = Vec::new(); + el(&mut o, SEEK, &s); + o + }; + let seek_head = |first: u64| { + let entries: Vec = (0..n).flat_map(|i| entry(first + i * 12)).collect(); + let mut o = Vec::new(); + el(&mut o, SEEK_HEAD, &entries); + o + }; + let mut f = Vec::new(); + let mut h = Vec::new(); + el_str(&mut h, DOC_TYPE, "matroska"); + el(&mut f, EBML, &h); + write_id(&mut f, SEGMENT); + write_unknown_size(&mut f, 8); + let first = seek_head(0).len() as u64; + f.extend_from_slice(&seek_head(first)); + let total = n * 12 + pad as u64; + for i in 0..n { + write_id(&mut f, TAGS); + ebml::write_size(&mut f, total - (i + 1) * 12, 8); + } + f.resize(f.len() + pad, 0xEC); + let src = Counting(&f, std::cell::Cell::new(0)); + let file = open_with(&src, &OpenOptions { index: false, verify_crc: false }).unwrap(); + assert_eq!(file.seek_head.len(), n as usize); + assert!(src.1.get() <= 4 * f.len() as u64, "read {} bytes of a {}-byte file", src.1.get(), f.len()); +} diff --git a/crates/media/src/matroska/track.rs b/crates/media/src/matroska/track.rs index 2b0b4494..9a5dfd7a 100644 --- a/crates/media/src/matroska/track.rs +++ b/crates/media/src/matroska/track.rs @@ -265,10 +265,16 @@ impl Track { p } + /// Length of [`frame_prefix`](Self::frame_prefix), without building it. + pub fn frame_prefix_len(&self) -> u64 { + let stripped = self.stripped_header().map_or(0, <[u8]>::len) as u64; + if matches!(self.codec, Codec::ProRes { .. }) { stripped + 8 } else { stripped } + } + /// Frame size of sample `i` as returned by `read_sample` / packets (stored size plus prefix). pub fn sample_size(&self, i: usize) -> Option { let s = self.samples.get(i)?; - Some(s.size as u64 + self.frame_prefix(s.size as u64).len() as u64) + Some(s.size as u64 + self.frame_prefix_len()) } /// Indices of keyframe samples, ascending by file order. diff --git a/crates/media/tests/oracle_mux.rs b/crates/media/tests/oracle_mux.rs index 9a235884..1294965a 100644 --- a/crates/media/tests/oracle_mux.rs +++ b/crates/media/tests/oracle_mux.rs @@ -93,6 +93,7 @@ fn stream_packets(path: &Path) -> Option>> { /// Compare ffprobe's view of two files (per-stream packets: pts, dts, size, key flag). fn assert_same_packets(a: &Path, b: &Path) { let (Some(pa), Some(pb)) = (stream_packets(a), stream_packets(b)) else { return }; + assert_eq!(pa.len(), pb.len(), "stream count {} vs {}", a.display(), b.display()); for (i, (x, y)) in pa.iter().zip(&pb).enumerate() { let key = |p: &Value| (int(&p["pts"]), int(&p["dts"]), int(&p["size"]), p["flags"].as_str().map(|f| f.starts_with('K'))); let kx: Vec<_> = x.iter().map(key).collect(); @@ -106,10 +107,11 @@ fn assert_same_packets(a: &Path, b: &Path) { fn assert_same_streams(a: &Path, b: &Path) { let (Some(sa), Some(sb)) = (streams(a), streams(b)) else { return }; + // Timecode (data) streams are compared by the tests that add them. + let media = |s: Vec| s.into_iter().filter(|x| x["codec_type"] != "data").collect::>(); + let (sa, sb) = (media(sa), media(sb)); + assert_eq!(sa.len(), sb.len(), "stream count {} vs {}", a.display(), b.display()); for (x, y) in sa.iter().zip(&sb) { - if x["codec_type"] == "data" { - continue; - } for k in ["codec_name", "width", "height", "sample_rate", "channels", "nb_frames", "duration_ts", "time_base", "profile", "pix_fmt"] { assert_eq!(x[k], y[k], "stream field {k}"); } diff --git a/crates/media/tests/roundtrip.rs b/crates/media/tests/roundtrip.rs index 95fc88e3..1f21680b 100644 --- a/crates/media/tests/roundtrip.rs +++ b/crates/media/tests/roundtrip.rs @@ -2,7 +2,8 @@ use craft_media::isobmff::*; use proptest::prelude::*; -use std::io::Cursor; +use std::collections::BTreeMap; +use std::io::{Cursor, Seek, SeekFrom, Write}; #[derive(Clone, Debug)] struct S { @@ -164,9 +165,9 @@ fn sample_lookup() { } #[test] -fn large_offsets_use_co64_and_largesize_mdat() { - // Files > 4 GiB are impractical in tests, so patch a small file into the same layout the - // writer produces for them: co64 chunk offsets and a 16-byte large-size mdat header. +fn patched_co64_and_largesize_mdat_are_read() { + // A small file patched into the layout of a file past 4 GiB: co64 chunk offsets and a + // 16-byte large-size mdat header. let mut moov = Vec::new(); let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); let t = w.add_track(TrackConfig::new(entry(3), 30)).unwrap(); @@ -221,3 +222,95 @@ fn all_empty_samples_roundtrip() { assert!(f.tracks[0].samples.iter().all(|s| s.size == 0)); } } + +/// An output that keeps only writes under 1 MiB and reads everything else as zeros, so tests can +/// write files past 4 GiB from a reused zero buffer. +#[derive(Default)] +struct Sparse { + pos: u64, + len: u64, + kept: BTreeMap>, +} + +impl Write for Sparse { + fn write(&mut self, b: &[u8]) -> std::io::Result { + let end = self.pos + b.len() as u64; + self.kept.retain(|&at, _| at < self.pos || at >= end); + if b.len() < 1 << 20 { + self.kept.insert(self.pos, b.to_vec()); + } + self.pos += b.len() as u64; + self.len = self.len.max(self.pos); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Seek for Sparse { + fn seek(&mut self, to: SeekFrom) -> std::io::Result { + self.pos = match to { + SeekFrom::Start(p) => p, + SeekFrom::End(d) => self.len.checked_add_signed(d).unwrap(), + SeekFrom::Current(d) => self.pos.checked_add_signed(d).unwrap(), + }; + Ok(self.pos) + } +} + +impl ByteSource for Sparse { + fn len(&self) -> u64 { + self.len + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + buf.fill(0); + let end = offset + buf.len() as u64; + for (&at, data) in &self.kept { + let (lo, hi) = (at.max(offset), (at + data.len() as u64).min(end)); + if lo < hi { + buf[(lo - offset) as usize..(hi - offset) as usize].copy_from_slice(&data[(lo - at) as usize..(hi - at) as usize]); + } + } + Ok(()) + } +} + +const MIB: usize = 1 << 20; + +#[test] +fn writer_uses_co64_and_largesize_mdat_past_4_gib() { + let zeros = vec![0u8; MIB]; + let mut w = Mp4Writer::new(Sparse::default(), WriterOptions::new(Brand::Mp4)).unwrap(); + let t = w.add_track(TrackConfig::new(entry(3), 30)).unwrap(); + let n = 4 * 1024 + 1; + for _ in 0..n { + w.write_sample(t, WriteSample { data: &zeros, duration: 1, composition_offset: 0, is_sync: true }).unwrap(); + } + w.write_sample(t, WriteSample { data: b"abc", duration: 1, composition_offset: 0, is_sync: true }).unwrap(); + let out = w.finish().unwrap(); + let f = open(&out).unwrap(); + let s = &f.tracks[0].samples; + assert_eq!(s.len(), n + 1); + assert!(s[n].offset > u64::from(u32::MAX)); + assert_eq!(f.read_sample(&out, 0, n).unwrap(), b"abc"); +} + +#[test] +fn pcm_past_4_gib_reads_back_whole() { + let pcm = PcmConfig { bits: 16, float: false, big_endian: true, signed: true, channels: 2, sample_rate: 48000.0 }; + let zeros = vec![0u8; MIB]; + let mut w = Mp4Writer::new(Sparse::default(), WriterOptions::new(Brand::Mov)).unwrap(); + let t = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap(); + let writes = 4 * 1024 + 1; + for _ in 0..writes { + w.write_sample(t, WriteSample { data: &zeros, duration: 0, composition_offset: 0, is_sync: true }).unwrap(); + } + let out = w.finish().unwrap(); + let f = open(&out).unwrap(); + let s = &f.tracks[0].samples; + let bytes = (writes * MIB) as u64; + assert_eq!(s.iter().map(|s| u64::from(s.size)).sum::(), bytes); + assert_eq!(s.iter().map(|s| u64::from(s.duration)).sum::(), bytes / 4); + assert!(s.iter().all(|s| s.size <= 1 << 20)); +} diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml new file mode 100644 index 00000000..c71d6dbd --- /dev/null +++ b/crates/platform/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "craft-platform" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +description = "Small platform lifecycle services for the Craft suite" + +[lints] +workspace = true + +[features] +default = [] +dialogs = [] +appearance = ["dep:zbus", "dep:futures-lite", "dep:async-channel", "dep:async-io", "dep:async-process"] +browser = ["dep:wasm-bindgen", "dep:js-sys", "dep:web-sys"] +egui = ["dep:egui"] + +[dependencies] +egui = { workspace = true, optional = true } + +[target.'cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))'.dependencies] +zbus = { version = "5", default-features = false, features = ["async-io"], optional = true } +futures-lite = { version = "2", optional = true } +async-channel = { version = "2", optional = true } +async-io = { version = "2", optional = true } +async-process = { version = "2", optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = { version = "0.2", optional = true } +js-sys = { version = "0.3", optional = true } +web-sys = { version = "0.3", features = ["Window", "Document", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Node", "Element"], optional = true } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" +wasm-bindgen-futures = "0.4" diff --git a/crates/platform/LICENSE-APACHE b/crates/platform/LICENSE-APACHE new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/crates/platform/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/crates/platform/LICENSE-MIT b/crates/platform/LICENSE-MIT new file mode 100644 index 00000000..e1450bdf --- /dev/null +++ b/crates/platform/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArtCraft Team and the craft-libs contributors + +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/crates/platform/NOTICE b/crates/platform/NOTICE new file mode 100644 index 00000000..c369e009 --- /dev/null +++ b/crates/platform/NOTICE @@ -0,0 +1,16 @@ +Platform lifecycle source provenance + +The optional crates/platform services adapt the preserved local platform candidate +9b135779a1e7136438a217c18afe1693ea23f1ab (dialog outcomes and browser resource ownership), +and current application implementations: +- PdfCraft crates/platform/src/desktop_theme.rs, a8cd797a917b1007544575c7280c9a0ce6a17e8f. +- CADCraft apps/cadcraft/src/desktop_theme.rs, 0cc66ad3066e71f2c3d4cacb7de905e79ba1c95b. +- ArtCraft Launcher apps/artcraft-launcher/src/system_theme.rs, f1676dfb41c226b5be490721f486ee6427419d80. +- Earlier PhotoCraft, FilmCraft and EffectCraft appearance services at revisions + 0c72d95425dece90ef9a1cceb49e3315c96e22d5, 794a25fc9ed2e3f6fab51a254625bc9b99a88015 + and 1b4d47f34f2b1c1b4a89f4b7348889def8fd648b respectively. +Sources: https://github.com/storytold/pdfcraft, https://github.com/storytold/cadcraft, +https://github.com/storytold/craft-launcher, https://github.com/storytold/photocraft, +https://github.com/storytold/filmcraft, https://github.com/storytold/effectcraft. +Copyright (c) 2026 ArtCraft Team and the respective application and craft-libs contributors. +All adapted source is licensed MIT OR Apache-2.0. diff --git a/crates/platform/README.md b/crates/platform/README.md new file mode 100644 index 00000000..14494d61 --- /dev/null +++ b/crates/platform/README.md @@ -0,0 +1,81 @@ +# craft-platform + +Small platform lifecycle services for Craft applications. Application state, settings, palettes, +document policy and native command routing remain in their current owners. Defaults add no +third-party dependencies. Enable only the services a host uses. + +| Feature | Shared responsibility | +| --- | --- | +| default | Typed dialog outcomes; no backend or toolkit. | +| `dialogs` | Reserve waiting workers before eager UI-thread native picker creation; serial requests, failure delivery and completion wakeups. The backend factory belongs to the app. | +| `egui` | Combine with `dialogs` for the deferred-close guard or with `appearance` for native theme mapping. | +| `appearance` | Linux/BSD XDG desktop preference observation, owned cancellable subscriptions, bounded reads/helper capture, legacy compatibility and reconnect. | +| `browser` | Wasm download link/object URL ownership, cleanup on setup failure or abandonment, and cleanup timer registration before clicking. | + + +## Native dialogs + +`dialogs::wait_prepared(create, deliver)` reserves the worker first. Call it on the native UI thread. +`create` must call `pick_file`, `save_file`, or the equivalent backend method **before returning its +future**. Putting that call inside an async block delays native sheet creation until the worker +polls it. Worker reservation failure never opens the sheet. No live picker has a timeout. + +`NativeDialogs` provides an optional serial queue. Pump it on the event-loop +thread before consuming answers and after code that can request a picker. Replies become visible +only after the pump releases transport ownership, so a continuation can enqueue the next picker. +Dropping a stale reply does not release the actual picker. A host-specific preflight is optional; +it runs off-thread and must be bounded. Unknown capability permits a picker. Proven absence may +request an application-owned fallback. Custom save-format sheets and portal choices remain intact. + +`Outcome::Selected`, explicit `Cancelled`, `Failed` and `Indeterminate` have different evidence. +An rfd `None` is `Indeterminate`; it cannot prove user cancellation rather than backend failure. +Disconnected workers are failures. Apps own localization, document identity/revision checks, filters, +actual save receipts, chained continuations and the decision to close. + +Retain the actual parent window through queued requests, preflight, the native picker AND any +unconsumed application continuation. `DeferredClose::update` cancels close during pending work and +returns a request to retry the application's normal close policy afterward. It never grants +permission to discard work. Run those guards before `craft_quit::on_close_request` in `App::logic`. + +## Desktop appearance + +`appearance::Watcher` caches the desktop preference and wakes only on changes. Subscription precedes +the first read. ReadOne/legacy Read, the standard namespace and GNOME fallback are handled together; +malformed values retain the last valid answer, and explicit no-preference clears it. Disconnection +retains the last valid value until a fresh answer. A later service owner triggers a reread; session +reconnection has bounded backoff. Dropping cancels asynchronous work and waits at most 100 ms +for acknowledgement, joining only an already-finished worker. A synchronously blocked OS call or caller wake callback can outlive +the watcher: that exceptional worker is detached and retains its resources until it returns, +possibly until process exit. Wake callbacks must be nonblocking. No per-helper reader threads +are created. + +App-selected Light/Dark/System, custom palettes, window decorations and unknown-theme fallback remain +application policy. `set_active(false)` suppresses wake notifications while continuing to cache. +Four existing custom-palette service hooks retain their current signatures and receive change-only +notifications even in manual mode; they do not change the chosen palette. Never infer Auto from the +egui theme preference when an app implements Auto by explicitly applying a Light/Dark palette. + +`sync_native_window_theme(ctx, observed)` forwards the cached Linux/BSD desktop answer to native +decorations when egui's window policy is System, falling back to the current toolkit answer. +Its output hook runs after egui's automatic +window synchronization, including child-window output. Concrete Light/Dark preferences retain +precedence, absence of both answers releases the native pin, and `sync_window_theme = false` opts out. +It does not change styles or theme preference. Hosts whose custom palette is manual but whose +native window follows System keep observation active for the native window. Native decoration +rendering still depends on the window manager and requires visible acceptance testing. + +## Browser downloads + +`PreparedDownload::{bytes,blob}` owns a detached anchor and URL until click or drop. Call `click` +with the application's positive URL lifetime; setup/attachment/timer failure releases resources +without clicking. A successful return means a click was issued, not that the browser saved a file. +Applications own names, MIME types, pacing, capture hooks and user activation. File System Access +and print iframe lifetimes remain separate. This service has no native filesystem semantics. + +## Integration + +Enable only the services a host uses. Applications retain palettes, settings, document identity, +close policy, filters, names, MIME types and native command routing. Existing craft-settings, +craft-atomic, craft-print, craft-docpath and craft-quit remain canonical. `craft-window` owns +decoration selection and window-button layout; appearance observation and native color hints here +do not choose a frame or draw an application header. diff --git a/crates/platform/src/appearance.rs b/crates/platform/src/appearance.rs new file mode 100644 index 00000000..c696510d --- /dev/null +++ b/crates/platform/src/appearance.rs @@ -0,0 +1,635 @@ +//! Desktop colour preference through the XDG Settings portal. +//! +//! Derived from PdfCraft's activation-safe subscription and GNOME schema fallback, +//! CADCraft's BSD support, and ArtCraft Launcher's ReadOne/Read compatibility. +//! Palettes, manual choices and the fallback when no preference exists belong to the host. +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, Ordering}, +}; + +/// Desktop color preference observed independently of the application palette. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Theme { + /// The desktop requests a dark appearance. + Dark, + /// The desktop requests a light appearance. + Light, +} + +struct Shared { + value: AtomicU8, + active: AtomicBool, + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + wake: Box, +} +impl Shared { + fn get(&self) -> Option { + match self.value.load(Ordering::Acquire) { + 1 => Some(Theme::Dark), + 2 => Some(Theme::Light), + _ => None, + } + } + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + fn publish(&self, theme: Option) { + let code = match theme { + Some(Theme::Dark) => 1, + Some(Theme::Light) => 2, + None => 0, + }; + if self.value.swap(code, Ordering::AcqRel) != code && self.active.load(Ordering::Acquire) { + (self.wake)(); + } + } +} +/// An owned subscription. Dropping cancels connection setup, reads, backoff and helper capture, +/// and waits at most 100 ms for acknowledgement. Only a finished worker is joined: an OS call +/// or caller wake callback can block synchronously, so an exceptional unfinished worker is +/// detached and retains its resources until it returns (possibly until process exit). +/// Cache reads never block the UI; callers should keep wake callbacks nonblocking. +pub struct Watcher { + shared: Arc, + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + stop: async_channel::Sender<()>, + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + worker: Option>, + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + finished: std::sync::mpsc::Receiver<()>, +} +impl Watcher { + /// Start an active subscription, or return `None` on unsupported targets or worker failure. + /// `wake` runs on the worker and must not block. + pub fn start(wake: impl Fn() + Send + Sync + 'static) -> Option { + Self::start_active(true, wake) + } + /// Start observing with explicit control over wake notifications. + /// An inactive subscription still updates its cache. Unsupported targets and worker + /// startup failure return `None`; browser targets never start a desktop watcher. + pub fn start_active(active: bool, wake: impl Fn() + Send + Sync + 'static) -> Option { + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + { + Self::spawn(active, wake, portal::watch) + } + #[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))] + { + let _ = (active, wake); + None + } + } + /// Read the cached preference without blocking; `None` means no known preference. + pub fn current(&self) -> Option { + self.shared.get() + } + /// Enable or suppress future change notifications without stopping observation. + /// Changing this flag does not itself wake the host or clear the cached preference. + pub fn set_active(&self, active: bool) { + self.shared.active.store(active, Ordering::Release); + } + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + fn spawn + 'static>( + active: bool, + wake: impl Fn() + Send + Sync + 'static, + source: impl FnOnce(Arc) -> F + Send + 'static, + ) -> Option { + let shared = Arc::new(Shared { value: AtomicU8::new(0), active: AtomicBool::new(active), wake: Box::new(wake) }); + let worker_shared = Arc::clone(&shared); + let (stop, stopped) = async_channel::bounded::<()>(1); + let (done, finished) = std::sync::mpsc::channel(); + let worker = std::thread::Builder::new() + .name("desktop-appearance".into()) + .spawn(move || { + futures_lite::future::block_on(futures_lite::future::race( + async { + let _ = stopped.recv().await; + }, + source(worker_shared), + )); + let _ = done.send(()); + }) + .ok()?; + Some(Self { shared, stop, worker: Some(worker), finished }) + } +} +impl Drop for Watcher { + fn drop(&mut self) { + self.set_active(false); + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + { + self.stop.close(); + if let Some(worker) = self.worker.take() { + let _ = self.finished.recv_timeout(std::time::Duration::from_millis(100)); + // Acknowledgement precedes thread-local destructors. They can block too, so + // never join solely on the acknowledgement (or channel disconnection). + if worker.is_finished() { + let _ = worker.join(); + } + } + } + } +} + +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod portal { + use super::{Shared, Theme}; + use futures_lite::StreamExt; + use std::{sync::Arc, time::Duration}; + use zbus::zvariant::{OwnedValue, Value}; + const DEST: &str = "org.freedesktop.portal.Desktop"; + const PATH: &str = "/org/freedesktop/portal/desktop"; + const IFACE: &str = "org.freedesktop.portal.Settings"; + const STANDARD: &str = "org.freedesktop.appearance"; + const GNOME: &str = "org.gnome.desktop.interface"; + const KEY: &str = "color-scheme"; + const TIMEOUT: Duration = Duration::from_secs(2); + + // Outer None means unknown/malformed; Some(None) is an explicit no-preference answer. + fn decode(namespace: &str, value: &Value<'_>) -> Option> { + match (namespace, value) { + (STANDARD, Value::U32(n)) => Some(match n { + 1 => Some(Theme::Dark), + 2 => Some(Theme::Light), + _ => None, + }), + (GNOME, Value::Str(s)) => Some(match s.as_str() { + "prefer-dark" => Some(Theme::Dark), + "prefer-light" => Some(Theme::Light), + _ => None, + }), + _ => None, + } + } + fn reply(namespace: &str, value: &Value<'_>, legacy: bool) -> Option> { + if legacy { + let Value::Value(inner) = value else { return None }; + decode(namespace, inner) + } else { + decode(namespace, value) + } + } + fn combined(standard: Option>, gnome: Option>) -> Option> { + if standard.is_none() && gnome.is_none() { None } else { Some(standard.flatten().or(gnome.flatten())) } + } + async fn bounded(future: impl std::future::Future) -> Option { + futures_lite::future::race(async { Some(future.await) }, async { + async_io::Timer::after(TIMEOUT).await; + None + }) + .await + } + async fn read(connection: &zbus::Connection, namespace: &str) -> Option> { + for (method, legacy) in [("ReadOne", false), ("Read", true)] { + if let Some(Ok(message)) = bounded(connection.call_method(Some(DEST), PATH, Some(IFACE), method, &(namespace, KEY))).await + && let Ok(value) = message.body().deserialize::() + && let Some(answer) = reply(namespace, &value, legacy) + { + return Some(answer); + } + } + None + } + fn rule() -> zbus::Result> { + Ok(zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender(DEST)? + .path(PATH)? + .interface(IFACE)? + .member("SettingChanged")? + .arg(1, KEY)? + .build()) + } + fn owner_rule() -> zbus::Result> { + Ok(zbus::MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus")? + .interface("org.freedesktop.DBus")? + .member("NameOwnerChanged")? + .arg(0, DEST)? + .build()) + } + pub(super) async fn watch(shared: Arc) { + let mut retry = Duration::from_secs(5); + let mut fallback_used = false; + loop { + let mut connected = false; + let _ = session(&shared, &mut fallback_used, &mut connected).await; + if connected { + retry = Duration::from_secs(5); + } + // Keep the last valid answer during disconnect; a new explicit no-preference clears it. + async_io::Timer::after(retry).await; + retry = (retry * 2).min(Duration::from_secs(300)); + } + } + // Keep Nix/profile PATH entries, but never execute a helper from a relative search folder. + fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option { + let dirs = path.map(std::env::split_paths).into_iter().flatten().filter(|p| p.is_absolute()); + dirs.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|p| p.join("gsettings")).find(|p| p.is_file()) + } + async fn fallback(shared: &Shared, used: &mut bool) { + if *used { + return; + } + *used = true; + if let Some(exe) = gsettings_exe(std::env::var_os("PATH").as_deref()) + && let Some(text) = crate::process::capture(&exe, &["get", GNOME, KEY], TIMEOUT).await + { + let text = text.trim().trim_matches('\''); + if let Some(answer) = decode(GNOME, &Value::from(text)) { + shared.publish(answer); + } + } + } + async fn session(shared: &Shared, fallback_used: &mut bool, connected: &mut bool) -> Option<()> { + let connection = match zbus::connection::Builder::session() { + Ok(builder) => bounded(builder.method_timeout(TIMEOUT).build()).await.and_then(Result::ok), + Err(_) => None, + }; + let Some(connection) = connection else { + fallback(shared, fallback_used).await; + return None; + }; + watch_connection(shared, fallback_used, connected, connection).await + } + async fn watch_connection(shared: &Shared, fallback_used: &mut bool, connected: &mut bool, connection: zbus::Connection) -> Option<()> { + // MatchRule installs before activation: unlike a proxy subscription it needs no current owner. + let mut changes = bounded(zbus::MessageStream::for_match_rule(rule().ok()?, &connection, Some(64))).await?.ok()?; + let mut owners = bounded(zbus::MessageStream::for_match_rule(owner_rule().ok()?, &connection, Some(8))).await?.ok()?; + let mut standard = read(&connection, STANDARD).await; + let mut gnome = read(&connection, GNOME).await; + match combined(standard, gnome) { + Some(answer) => { + *connected = true; + shared.publish(answer); + } + None => { + fallback(shared, fallback_used).await; + return None; + } + } + loop { + let message = + futures_lite::future::race(async { changes.next().await.map(|m| (false, m)) }, async { owners.next().await.map(|m| (true, m)) }) + .await?; + let (owner, message) = message; + let message = message.ok()?; + if owner { + // Activation is already covered by the initial read. Loss/replacement reconnects. + if let Ok((_, old, _)) = message.body().deserialize::<(String, String, String)>() + && !old.is_empty() + { + return None; + } + continue; + } + let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, OwnedValue)>() else { continue }; + if key != KEY { + continue; + } + let Some(answer) = decode(&namespace, &value) else { continue }; + match namespace.as_str() { + STANDARD => standard = Some(answer), + GNOME => gnome = Some(answer), + _ => continue, + } + if let Some(answer) = combined(standard, gnome) { + shared.publish(answer); + } + } + } + #[cfg(test)] + #[path = "appearance_bus_tests.rs"] + mod bus_tests; + #[cfg(test)] + mod tests { + #[test] + fn gsettings_respects_absolute_nix_style_path_without_using_relative_folders() -> Result<(), Box> { + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos(); + let directory = std::env::temp_dir().join(format!("craft-platform-gsettings-{}-{nonce}", std::process::id())); + std::fs::create_dir(&directory)?; + let fixture = Fixture(directory); + let helper = fixture.0.join("gsettings"); + std::fs::write(&helper, b"fixture")?; + let path = std::env::join_paths([std::path::Path::new("relative-untrusted"), fixture.0.as_path()])?; + assert_eq!(super::gsettings_exe(Some(path.as_os_str())), Some(helper)); + assert!(super::gsettings_exe(Some(std::ffi::OsStr::new("relative-untrusted"))).is_none_or(|p| p.is_absolute())); + Ok(()) + } + + use super::{GNOME, STANDARD, combined, decode, reply}; + use crate::appearance::{Theme, Watcher}; + use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, + }; + use zbus::zvariant::Value; + #[test] + fn no_preference_is_an_answer_and_gnome_only_fills_missing_preference() { + assert_eq!(combined(None, None), None); + assert_eq!(combined(Some(None), None), Some(None)); + assert_eq!(combined(Some(None), Some(Some(Theme::Dark))), Some(Some(Theme::Dark))); + assert_eq!(combined(Some(Some(Theme::Light)), Some(Some(Theme::Dark))), Some(Some(Theme::Light))); + assert_eq!(combined(None, Some(None)), Some(None)); + } + #[test] + fn malformed_is_distinct_from_no_preference_and_legacy_is_exact() { + assert_eq!(decode(STANDARD, &Value::U32(0)), Some(None)); + assert_eq!(decode(STANDARD, &Value::U32(77)), Some(None)); + assert_eq!(decode(STANDARD, &Value::from("dark")), None); + assert_eq!(reply(STANDARD, &Value::Value(Box::new(Value::U32(1))), true), Some(Some(Theme::Dark))); + assert_eq!(reply(STANDARD, &Value::U32(1), true), None); + assert_eq!(reply(STANDARD, &Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))), true), None); + assert_eq!(decode(STANDARD, &Value::Value(Box::new(Value::U32(1)))), None); + assert_eq!(decode(STANDARD, &Value::I32(1)), None); + assert_eq!(decode(GNOME, &Value::from("default")), Some(None)); + assert_eq!(decode(GNOME, &Value::U32(1)), None); + + assert_eq!(reply(GNOME, &Value::from("prefer-light"), false), Some(Some(Theme::Light))); + } + #[test] + fn manual_cache_changes_do_not_wake_and_drop_cancels_pending_source() -> Result<(), Box> { + let (tx, rx) = std::sync::mpsc::channel(); + let (cancelled, cancellation) = std::sync::mpsc::channel(); + struct Cancelled(std::sync::mpsc::Sender<()>); + impl Drop for Cancelled { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + let wakes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = Arc::clone(&wakes); + let watcher = Watcher::spawn( + false, + move || { + observed.fetch_add(1, Ordering::Relaxed); + }, + move |shared| async move { + let _cancelled = Cancelled(cancelled); + shared.publish(Some(Theme::Dark)); + let _ = tx.send(()); + futures_lite::future::pending::<()>().await; + }, + ) + .ok_or("worker creation failed")?; + rx.recv_timeout(Duration::from_secs(2))?; + assert_eq!(watcher.current(), Some(Theme::Dark)); + assert_eq!(wakes.load(Ordering::Relaxed), 0); + watcher.set_active(true); + watcher.shared.publish(Some(Theme::Dark)); + assert_eq!(wakes.load(Ordering::Relaxed), 0); + watcher.shared.publish(Some(Theme::Light)); + assert_eq!(watcher.current(), Some(Theme::Light)); + watcher.shared.publish(Some(Theme::Dark)); + assert_eq!(watcher.current(), Some(Theme::Dark)); + watcher.shared.publish(None); + assert_eq!(watcher.current(), None); + assert_eq!(wakes.load(Ordering::Relaxed), 3); + drop(watcher); + cancellation.recv_timeout(Duration::from_secs(2))?; + Ok(()) + } + #[test] + fn drop_does_not_wait_for_a_synchronously_blocked_source() -> Result<(), Box> { + let (started, ready) = std::sync::mpsc::channel(); + let (release, blocked) = std::sync::mpsc::channel(); + let (exited, exit) = std::sync::mpsc::channel(); + struct Release(Option>); + impl Drop for Release { + fn drop(&mut self) { + if let Some(release) = self.0.take() { + let _ = release.send(()); + } + } + } + // Declared before the watcher: every error/unwind releases the synchronous poll. + let release = Release(Some(release)); + let watcher = Watcher::spawn( + false, + || {}, + move |_| async move { + let _ = started.send(()); + let _ = blocked.recv(); + let _ = exited.send(()); + }, + ) + .ok_or("worker creation failed")?; + ready.recv_timeout(Duration::from_secs(2))?; + let (dropped, drop_result) = std::sync::mpsc::channel(); + let dropper = std::thread::spawn(move || { + drop(watcher); + let _ = dropped.send(()); + }); + let bounded = drop_result.recv_timeout(Duration::from_secs(1)).is_ok(); + // Release before asserting, including when an unbounded implementation regresses. + drop(release); + exit.recv_timeout(Duration::from_secs(2))?; + assert!(dropper.join().is_ok(), "watcher drop panicked"); + assert!(bounded, "watcher drop waited for a blocked synchronous poll"); + Ok(()) + } + } +} +/// Owned callback that supplies an observed theme and updates egui native-window hints. +#[cfg(feature = "egui")] +pub type SystemThemeFn = Box Option>; +/// Create an egui theme callback that owns the subscription and wakes its first context. +/// Return `None` when no desktop watcher can start; the host retains its fallback policy. +#[cfg(feature = "egui")] +pub fn egui_service() -> Option { + let context = Arc::new(std::sync::OnceLock::::new()); + let worker_context = Arc::clone(&context); + let watcher = Watcher::start(move || { + if let Some(ctx) = worker_context.get() { + ctx.request_repaint(); + } + })?; + Some(Box::new(move |ctx| { + let _ = context.set(ctx.clone()); + sync_native_window_theme(ctx, watcher.current()); + watcher.current().map(|t| match t { + Theme::Dark => egui::Theme::Dark, + Theme::Light => egui::Theme::Light, + }) + })) +} + +/// Supply the portal answer for native window decorations without changing egui's theme choice +/// or either palette. A missing portal answer falls back to the current toolkit system theme; +/// when both are absent the native pin is released. Linux/BSD window backends cannot reliably +/// resolve `SystemDefault` themselves. +/// The output hook runs after egui's own window-theme synchronization, so it cannot be overwritten +/// by a later automatic `SetTheme(SystemDefault)` in the same pass. +#[cfg(feature = "egui")] +pub fn sync_native_window_theme(ctx: &egui::Context, observed: Option) { + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + { + ctx.add_plugin(NativeWindowTheme::default()); + ctx.with_plugin(|plugin: &mut NativeWindowTheme| plugin.observed = observed); + } + #[cfg(not(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))))] + let _ = (ctx, observed); +} + +#[cfg(all(feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +#[derive(Default)] +struct NativeWindowTheme { + observed: Option, + sent: std::collections::HashMap, +} +#[cfg(all(feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +impl egui::Plugin for NativeWindowTheme { + fn debug_name(&self) -> &'static str { + "desktop window appearance" + } + fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) { + if !ctx.options(|o| o.sync_window_theme) { + // Another owner may alter the native theme while synchronization is disabled. + self.sent.clear(); + return; + } + let desired = match ctx.options(|o| o.theme_preference) { + egui::ThemePreference::Dark => egui::SystemTheme::Dark, + egui::ThemePreference::Light => egui::SystemTheme::Light, + egui::ThemePreference::System => match self.observed.or_else(|| { + ctx.system_theme().map(|theme| match theme { + egui::Theme::Dark => Theme::Dark, + egui::Theme::Light => Theme::Light, + }) + }) { + Some(Theme::Dark) => egui::SystemTheme::Dark, + Some(Theme::Light) => egui::SystemTheme::Light, + None => egui::SystemTheme::SystemDefault, + }, + }; + // FullOutput can carry child-window commands too. Rewrite every viewport so a deferred + // or immediate child cannot reintroduce egui's inherited hint after its own UI pass. + for (id, viewport) in &mut output.viewport_output { + let had_sync = viewport.commands.iter().any(|command| matches!(command, egui::ViewportCommand::SetTheme(_))); + viewport.commands.retain(|command| !matches!(command, egui::ViewportCommand::SetTheme(_))); + if had_sync || self.sent.get(id) != Some(&desired) { + viewport.commands.push(egui::ViewportCommand::SetTheme(desired)); + self.sent.insert(*id, desired); + } + } + // Forget destroyed native windows: a reused viewport identifier must receive its hint. + self.sent.retain(|id, _| output.viewport_output.contains_key(id)); + } +} + +#[cfg(all(test, feature = "egui", unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod native_window_tests { + use super::{Theme, sync_native_window_theme}; + fn pass(ctx: &egui::Context, observed: Option, preference: egui::ThemePreference) -> Vec { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + sync_native_window_theme(ui.ctx(), observed); + // Deliberately apply the host's choice AFTER the observer, as real palette adapters do. + ui.ctx().set_theme(preference); + }); + output.textures_delta.clear(); // Headless policy test; no renderer consumes uploads. + output + .viewport_output + .values() + .flat_map(|v| &v.commands) + .filter_map(|command| match command { + egui::ViewportCommand::SetTheme(theme) => Some(*theme), + _ => None, + }) + .collect() + } + #[test] + fn actual_end_pass_maps_system_manual_and_reset_without_overwriting_palettes() { + use egui::{SystemTheme as Native, ThemePreference as Choice}; + let ctx = egui::Context::default(); + let marker = egui::Color32::from_rgb(12, 34, 56); + ctx.all_styles_mut(|s| s.visuals.panel_fill = marker); + assert_eq!(pass(&ctx, Some(Theme::Light), Choice::System), vec![Native::Light]); + assert_eq!(ctx.options(|o| o.theme_preference), Choice::System); + assert!(pass(&ctx, Some(Theme::Light), Choice::System).is_empty()); + assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]); + // A portal change cannot override a concrete manual native preference. + assert_eq!(pass(&ctx, Some(Theme::Light), Choice::Dark), vec![Native::Dark]); + assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::Light), vec![Native::Light]); + assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]); + assert_eq!(pass(&ctx, None, Choice::System), vec![Native::SystemDefault]); + assert!(pass(&ctx, None, Choice::System).is_empty()); + assert_eq!(ctx.style_of(egui::Theme::Dark).visuals.panel_fill, marker); + assert_eq!(ctx.style_of(egui::Theme::Light).visuals.panel_fill, marker); + } + #[test] + fn native_system_hint_uses_current_toolkit_fallback_without_retaining_stale_input() { + let ctx = egui::Context::default(); + for (raw_theme, observed, expected) in [ + (Some(egui::Theme::Light), None, egui::SystemTheme::Light), + (Some(egui::Theme::Dark), None, egui::SystemTheme::Dark), + (Some(egui::Theme::Dark), Some(Theme::Light), egui::SystemTheme::Light), + (None, None, egui::SystemTheme::SystemDefault), + ] { + let mut output = ctx.run_ui(egui::RawInput { system_theme: raw_theme, ..Default::default() }, |ui| { + sync_native_window_theme(ui.ctx(), observed); + ui.ctx().set_theme(egui::ThemePreference::System); + }); + let hints: Vec<_> = output + .viewport_output + .values() + .flat_map(|v| &v.commands) + .filter_map(|command| match command { + egui::ViewportCommand::SetTheme(theme) => Some(*theme), + _ => None, + }) + .collect(); + output.textures_delta.clear(); + assert_eq!(hints, vec![expected]); + } + } + #[test] + fn deferred_windows_receive_the_same_hint_and_reset() { + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let child = egui::ViewportId::from_hash_of("appearance-child"); + for observed in [Some(Theme::Light), Some(Theme::Dark), None] { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + sync_native_window_theme(ui.ctx(), observed); + ui.ctx().set_theme(egui::ThemePreference::System); + ui.ctx().show_viewport_deferred(child, egui::ViewportBuilder::default(), |_, _| {}); + }); + output.textures_delta.clear(); + let expected = match observed { + Some(Theme::Light) => egui::SystemTheme::Light, + Some(Theme::Dark) => egui::SystemTheme::Dark, + None => egui::SystemTheme::SystemDefault, + }; + for id in [egui::ViewportId::ROOT, child] { + let commands: Vec<_> = output + .viewport_output + .get(&id) + .into_iter() + .flat_map(|v| &v.commands) + .filter_map(|c| match c { + egui::ViewportCommand::SetTheme(t) => Some(*t), + _ => None, + }) + .collect(); + assert_eq!(commands, vec![expected]); + } + } + } + #[test] + fn custom_palette_system_policy_and_explicit_sync_opt_out_are_preserved() { + use egui::{SystemTheme as Native, ThemePreference as Choice}; + let ctx = egui::Context::default(); + // Photo/PDF/Sound keep System native policy even when their custom palette is manual. + ctx.all_styles_mut(|s| s.visuals = egui::Visuals::dark()); + assert_eq!(pass(&ctx, Some(Theme::Light), Choice::System), vec![Native::Light]); + assert!(ctx.style_of(egui::Theme::Light).visuals.dark_mode); + ctx.options_mut(|o| o.sync_window_theme = false); + assert!(pass(&ctx, Some(Theme::Dark), Choice::System).is_empty()); + ctx.options_mut(|o| o.sync_window_theme = true); + assert_eq!(pass(&ctx, Some(Theme::Dark), Choice::System), vec![Native::Dark]); + } +} diff --git a/crates/platform/src/appearance/portal/appearance_bus_tests.rs b/crates/platform/src/appearance/portal/appearance_bus_tests.rs new file mode 100644 index 00000000..d5923bc2 --- /dev/null +++ b/crates/platform/src/appearance/portal/appearance_bus_tests.rs @@ -0,0 +1,147 @@ +//! Private-bus regression based on PdfCraft's queued-update test (storytold/pdfcraft#881). +use super::{DEST, GNOME, IFACE, KEY, PATH, STANDARD, watch_connection}; +use crate::appearance::{Theme, Watcher}; +use std::time::Duration; +use zbus::zvariant::{OwnedValue, Value}; + +#[test] +#[ignore = "requires dbus-daemon; private session bus only"] +fn legacy_read_keeps_queued_signals_and_drop_cancels_idle_subscription() -> Result<(), Box> { + use std::io::BufRead; + use std::process::{Command, Stdio}; + struct Daemon(std::process::Child); + impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + struct Settings { + captured: std::sync::mpsc::Sender<()>, + released: async_channel::Receiver<()>, + } + #[zbus::interface(name = "org.freedesktop.portal.Settings")] + impl Settings { + async fn read(&self, namespace: &str, _key: &str) -> zbus::fdo::Result { + if namespace != STANDARD { + return Err(zbus::fdo::Error::InvalidArgs("unknown namespace".into())); + } + self.captured.send(()).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; + self.released.recv().await.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; + OwnedValue::try_from(Value::Value(Box::new(Value::U32(2)))).map_err(|e| zbus::fdo::Error::Failed(e.to_string())) + } + } + let mut daemon = Daemon( + Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .arg(format!("--address=unix:abstract=craft-appearance-test-{}", std::process::id())) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?, + ); + let mut address = String::new(); + std::io::BufReader::new(daemon.0.stdout.take().ok_or("no daemon stdout")?).read_line(&mut address)?; + let (captured, seen) = std::sync::mpsc::channel(); + let (release, released) = async_channel::bounded(1); + let server = futures_lite::future::block_on( + zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, Settings { captured, released })?.build(), + )?; + let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?; + let (woke, wakes) = std::sync::mpsc::channel(); + let tracker = Watcher::spawn( + true, + move || { + let _ = woke.send(()); + }, + move |shared| async move { + let _ = watch_connection(&shared, &mut true, &mut false, client).await; + }, + ) + .ok_or("could not start observer")?; + seen.recv_timeout(Duration::from_secs(2))?; + futures_lite::future::block_on(server.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(1u32))))?; + release.send_blocking(())?; + wakes.recv_timeout(Duration::from_secs(2))?; + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), Some(Theme::Dark)); + // A malformed update must not clear the valid value or cause a wake. + futures_lite::future::block_on(server.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(true))))?; + assert!(wakes.recv_timeout(Duration::from_millis(50)).is_err()); + assert_eq!(tracker.current(), Some(Theme::Dark)); + let before = std::time::Instant::now(); + drop(tracker); + assert!(before.elapsed() < Duration::from_secs(1)); + // Drop also cancels a backend that has entered Read and never replies. + let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?; + let pending = Watcher::spawn( + true, + || {}, + move |shared| async move { + let _ = watch_connection(&shared, &mut true, &mut false, client).await; + }, + ) + .ok_or("could not start pending-read observer")?; + seen.recv_timeout(Duration::from_secs(2))?; + let before = std::time::Instant::now(); + drop(pending); + assert!(before.elapsed() < Duration::from_secs(1)); + // A newly available portal uses ReadOne, and a replacement owner must trigger a reread. + futures_lite::future::block_on(server.release_name(DEST))?; + struct ModernSettings(u32); + #[zbus::interface(name = "org.freedesktop.portal.Settings")] + impl ModernSettings { + fn read_one(&self, namespace: &str, _key: &str) -> zbus::fdo::Result { + if namespace == STANDARD { Ok(OwnedValue::from(self.0)) } else { Err(zbus::fdo::Error::InvalidArgs("unknown namespace".into())) } + } + } + let client = futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.build())?; + let (woke, wakes) = std::sync::mpsc::channel(); + let (attempted, attempts) = std::sync::mpsc::channel(); + let tracker = Watcher::spawn( + true, + move || { + let _ = woke.send(()); + }, + move |shared| async move { + // The production observer repeats this session after its bounded reconnect backoff. + loop { + let _ = watch_connection(&shared, &mut true, &mut false, client.clone()).await; + let _ = attempted.send(()); + async_io::Timer::after(Duration::from_millis(10)).await; + } + }, + ) + .ok_or("could not start modern observer")?; + attempts.recv_timeout(Duration::from_secs(2))?; + let modern = + futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, ModernSettings(2))?.build())?; + + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), Some(Theme::Light)); + futures_lite::future::block_on(modern.release_name(DEST))?; + let replacement = + futures_lite::future::block_on(zbus::connection::Builder::address(address.trim())?.name(DEST)?.serve_at(PATH, ModernSettings(1))?.build())?; + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), Some(Theme::Dark)); + futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(STANDARD, KEY, OwnedValue::from(0u32))))?; + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), None); + // GNOME's string schema fills the empty standard preference. Manual mode still caches it, + // and opting back into System sees that latest cache without a redundant wake. + tracker.set_active(false); + futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(GNOME, KEY, Value::from("prefer-dark"))))?; + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while tracker.current() != Some(Theme::Dark) { + assert!(std::time::Instant::now() < deadline, "GNOME change was not cached"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(matches!(wakes.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))); + tracker.set_active(true); + assert_eq!(tracker.current(), Some(Theme::Dark)); + assert!(matches!(wakes.try_recv(), Err(std::sync::mpsc::TryRecvError::Empty))); + futures_lite::future::block_on(replacement.emit_signal(None::<&str>, PATH, IFACE, "SettingChanged", &(GNOME, KEY, Value::from("default"))))?; + wakes.recv_timeout(Duration::from_secs(2))?; + assert_eq!(tracker.current(), None); + drop(tracker); + Ok(()) +} diff --git a/crates/platform/src/browser.rs b/crates/platform/src/browser.rs new file mode 100644 index 00000000..965eb925 --- /dev/null +++ b/crates/platform/src/browser.rs @@ -0,0 +1,89 @@ +//! Browser downloads. Caller owns MIME/name decisions and pacing; this module owns resource cleanup. +use wasm_bindgen::{JsCast, JsValue, closure::Closure}; +use web_sys::{Blob, BlobPropertyBag, HtmlAnchorElement, Url, Window}; + +/// Object-URL lifetime for a requested download; the default is 60 seconds. +#[derive(Clone, Copy, Debug)] +pub struct DownloadOptions { + /// Positive delay in milliseconds before cleanup; nonpositive values reject the click. + pub revoke_after_ms: i32, +} +impl Default for DownloadOptions { + fn default() -> Self { + Self { revoke_after_ms: 60_000 } + } +} + +/// Prepared DOM link and object URL. Dropping without clicking cleans both up. +/// Preparation can occur before an app's download pacer yields. +pub struct PreparedDownload { + window: Window, + anchor: HtmlAnchorElement, + url: Option, +} +impl PreparedDownload { + /// Prepare an owned Blob and hidden link using the caller's bytes, MIME and filename. + /// No download is requested until [`Self::click`]; browser setup failures return an error. + pub fn bytes(name: &str, mime: &str, bytes: &[u8]) -> Result { + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let options = BlobPropertyBag::new(); + options.set_type(mime); + Self::blob(name, &Blob::new_with_u8_array_sequence_and_options(&parts, &options)?) + } + /// Prepare a hidden link and owned object URL for an existing Blob. + /// Blank filenames, missing browser DOM and setup failures return an error. + pub fn blob(name: &str, blob: &Blob) -> Result { + if name.trim().is_empty() { + return Err(JsValue::from_str("a file name is required")); + } + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no browser window"))?; + let document = window.document().ok_or_else(|| JsValue::from_str("no browser document"))?; + document.body().ok_or_else(|| JsValue::from_str("the page has no body"))?; + let anchor = document.create_element("a")?.dyn_into::()?; + anchor.set_download(name); + anchor.set_attribute("style", "display:none")?; + let download = Self { window, anchor, url: Some(Url::create_object_url_with_blob(blob)?) }; + if let Some(url) = &download.url { + download.anchor.set_href(url); + } + Ok(download) + } + /// Register cleanup before clicking: a timer failure returns an error without triggering + /// a partial download. Success only means a click was issued; browser acceptance is unobservable. + pub fn click(mut self, options: DownloadOptions) -> Result<(), JsValue> { + if options.revoke_after_ms <= 0 { + return Err(JsValue::from_str("download lifetime must be positive")); + } + let Some(url) = self.url.as_ref().cloned() else { + return Err(JsValue::from_str("download has no object URL")); + }; + let body = self.window.document().and_then(|d| d.body()).ok_or_else(|| JsValue::from_str("the page has no body"))?; + body.append_child(&self.anchor)?; + let revoke = Closure::once(move || { + let _ = Url::revoke_object_url(&url); + }); + self.window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.as_ref().unchecked_ref(), options.revoke_after_ms)?; + revoke.forget(); // Ownership transfers only after successful timer registration. + self.url = None; + self.anchor.click(); + Ok(()) + } +} +impl Drop for PreparedDownload { + fn drop(&mut self) { + self.anchor.remove(); + if let Some(url) = self.url.take() { + let _ = Url::revoke_object_url(&url); + } + } +} +/// Prepare bytes and request a download with the specified URL lifetime. +/// Success means the click was issued, not that the browser accepted or saved the file. +pub fn download_bytes(name: &str, mime: &str, bytes: &[u8], options: DownloadOptions) -> Result<(), JsValue> { + PreparedDownload::bytes(name, mime, bytes)?.click(options) +} +/// Prepare a Blob and request a download with the specified URL lifetime. +/// Setup or scheduling failures clean up owned resources and return an error. +pub fn download_blob(name: &str, blob: &Blob, options: DownloadOptions) -> Result<(), JsValue> { + PreparedDownload::blob(name, blob)?.click(options) +} diff --git a/crates/platform/src/dialog_outcome.rs b/crates/platform/src/dialog_outcome.rs new file mode 100644 index 00000000..7f675002 --- /dev/null +++ b/crates/platform/src/dialog_outcome.rs @@ -0,0 +1,97 @@ +//! Dialog results with evidence: an empty rfd answer does not establish user cancellation. +/// Host assessment of whether a native backend is available; unknown is not proven absence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Capability { + /// The host has confirmed that a backend is available. + Available, + /// Proven backend absence, with a diagnostic suitable for the host. + Unavailable(String), + /// Inconclusive capability evidence; the host should still permit a picker attempt. + Unknown(String), +} +/// The layer that prevented a dialog request from completing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ErrorKind { + /// The host proved that no backend is available. + Unavailable, + /// Request transport or background execution failed. + Worker, + /// The picker backend reported or encountered a failure. + Backend, +} +/// A classified dialog failure with a host-facing diagnostic. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DialogError { + /// The failing layer. + pub kind: ErrorKind, + /// Diagnostic text for the host to present or log. + pub message: String, +} +impl DialogError { + /// Construct a failure without inferring cancellation from the diagnostic. + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { kind, message: message.into() } + } +} +impl std::fmt::Display for DialogError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for DialogError {} +/// Selection, explicit cancellation, failure, or an answer whose cause is unknown. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + /// A value returned by the picker. + Selected(T), + /// Explicit app/backend cancellation; never inferred from rfd's empty answer. + Cancelled, + /// A transport, capability or backend failure. + Failed(DialogError), + /// No selection; rfd cannot say whether the user cancelled or its backend failed. + Indeterminate, +} +impl Outcome { + /// Preserve a selection; an empty rfd answer is indeterminate, never proven cancellation. + pub fn from_rfd(value: Option) -> Self { + value.map_or(Self::Indeterminate, Self::Selected) + } + /// Transform only a selected value, preserving all other outcomes unchanged. + pub fn map(self, map: impl FnOnce(T) -> U) -> Outcome { + match self { + Self::Selected(value) => Outcome::Selected(map(value)), + Self::Cancelled => Outcome::Cancelled, + Self::Failed(error) => Outcome::Failed(error), + Self::Indeterminate => Outcome::Indeterminate, + } + } + /// Construct a backend failure with the supplied diagnostic. + pub fn failed(message: impl Into) -> Self { + Self::Failed(DialogError::new(ErrorKind::Backend, message)) + } +} +impl Outcome> { + /// Treat missing and empty multi-selection answers as indeterminate. + pub fn from_rfd_files(value: Option>) -> Self { + Self::from_rfd(value.filter(|paths| !paths.is_empty())) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_and_empty_rfd_answers_never_establish_cancellation() { + assert_eq!(Outcome::::from_rfd(None), Outcome::Indeterminate); + assert_eq!(Outcome::>::from_rfd_files(Some(Vec::new())), Outcome::Indeterminate); + assert_eq!(Outcome::from_rfd_files(Some(vec!["a"])), Outcome::Selected(vec!["a"])); + for _capability in [Capability::Available, Capability::Unavailable("missing".into()), Capability::Unknown("timeout".into())] { + assert_eq!(Outcome::::from_rfd(None), Outcome::Indeterminate); + } + } + #[test] + fn mapping_preserves_explicit_cancellation_and_failure() { + assert_eq!(Outcome::::Cancelled.map(u32::from), Outcome::Cancelled); + assert_eq!(Outcome::::failed("worker stopped").map(u32::from), Outcome::failed("worker stopped")); + assert_eq!(Outcome::Selected(7_u8).map(u32::from), Outcome::Selected(7)); + } +} diff --git a/crates/platform/src/dialogs.rs b/crates/platform/src/dialogs.rs new file mode 100644 index 00000000..b09c66be --- /dev/null +++ b/crates/platform/src/dialogs.rs @@ -0,0 +1,258 @@ +//! UI-thread picker creation with reserved waiting workers. No backend or application policy. +//! +//! `create` must call the native picker method eagerly on the event-loop thread, returning its +//! already-created future. Hosts retain the parent through transport AND their continuation. +//! A missing backend answer is indeterminate; only explicit backend evidence proves cancellation. +use crate::dialog_outcome::{DialogError, ErrorKind, Outcome}; +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, Sender, SyncSender, TryRecvError}, + }, + thread::JoinHandle, +}; +/// An already-created native picker future that a reserved worker can wait on. +pub type DialogFuture = Pin> + Send>>; + +/// Wait for a future without polling an idle native dialog. +pub fn block_on(future: impl Future) -> T { + struct Unpark(std::thread::Thread); + impl std::task::Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } + } + let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current()))); + let mut context = std::task::Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + loop { + if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut context) { + return value; + } + std::thread::park(); + } +} +// Notification callbacks must not unwind a reserved worker after the UI observes readiness. +fn notify(wake: &dyn Fn()) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(wake)); +} +fn worker_error(message: impl Into) -> DialogError { + DialogError::new(ErrorKind::Worker, message) +} +fn guarded(f: impl FnOnce() -> Outcome) -> Outcome { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| Outcome::Failed(worker_error("The file-dialog backend failed"))) +} +/// Reserve the worker before opening a native sheet. A failed reservation never calls `create`. +/// No timeout is imposed on a live picker. Backend constructor/poll panics become explicit failures. +pub fn wait_prepared> + Send + 'static>( + create: impl FnOnce() -> F, + deliver: impl FnOnce(Outcome) + Send + 'static, +) -> std::io::Result> { + let (send, receive) = mpsc::channel::(); + let worker = std::thread::Builder::new().name("file-dialog".into()).spawn(move || { + deliver(guarded(|| match receive.recv() { + Ok(future) => block_on(future), + Err(_) => Outcome::Failed(worker_error("The file-dialog factory failed")), + })); + })?; + if let Ok(future) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(create)) { + send.send(future).map_err(|_| std::io::Error::other("The reserved file-dialog worker stopped"))?; + } + Ok(worker) +} + +type Job = (R, Sender>); +/// Clonable request endpoint. At most one queued, preparing or live request is accepted. +pub struct DialogClient { + sender: SyncSender>, + busy: Arc, + wake: Arc, +} +impl Clone for DialogClient { + fn clone(&self) -> Self { + Self { sender: self.sender.clone(), busy: self.busy.clone(), wake: self.wake.clone() } + } +} +impl DialogClient { + /// Queue a request without blocking and wake the host to pump it. + /// A busy or unavailable host answers with a worker failure; no second picker is opened. + pub fn request(&self, request: R) -> Receiver> { + let (answer, receive) = mpsc::channel(); + if self.busy.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { + let _ = answer.send(Outcome::Failed(worker_error("A file dialog is already pending"))); + } else if let Err(error) = self.sender.try_send((request, answer)) { + self.busy.store(false, Ordering::SeqCst); + let (_, answer) = match error { + mpsc::TrySendError::Full(job) | mpsc::TrySendError::Disconnected(job) => job, + }; + let _ = answer.send(Outcome::Failed(worker_error("The file-dialog host is unavailable"))); + } + notify(self.wake.as_ref()); + receive + } +} +struct Preparing { + request: R, + ready: Receiver<()>, + send: Sender>, + completion: Completion, +} +struct Completion { + done: Receiver>, + answer: Sender>, +} +impl Completion { + fn poll(&self) -> Option> { + match self.done.try_recv() { + Ok(outcome) => Some(outcome), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => Some(Outcome::Failed(worker_error("The file-dialog worker stopped without an answer"))), + } + } +} +enum State { + Preparing(Preparing), + Waiting(Completion), +} +/// Event-loop-owned transport. Dropping a reply does not release a live picker or its parent. +/// `pump` must run before consuming replies, and after app code that may enqueue a request. +/// The host must retain this service/window while `pending()` or its own continuation is pending. +pub struct NativeDialogs { + queue: Receiver>, + busy: Arc, + state: Option>, + wake: Arc, + check: Arc Result<(), DialogError> + Send + Sync>, +} +impl NativeDialogs { + /// Create a client and its event-loop-owned service without a backend preflight check. + pub fn new(wake: impl Fn() + Send + Sync + 'static) -> (DialogClient, Self) { + Self::with_preflight(wake, || Ok(())) + } + /// Optional host-owned advisory check, run on the reserved worker before any picker exists. + /// Hosts must bound this check. Unknown capability must permit the actual picker; return an + /// Unavailable error only when absence is proven. This service performs no filesystem probes. + pub fn with_preflight( + wake: impl Fn() + Send + Sync + 'static, + check: impl Fn() -> Result<(), DialogError> + Send + Sync + 'static, + ) -> (DialogClient, Self) { + let (sender, queue) = mpsc::sync_channel(1); + let busy = Arc::new(AtomicBool::new(false)); + let wake: Arc = Arc::new(wake); + (DialogClient { sender, busy: busy.clone(), wake: wake.clone() }, Self { queue, busy, state: None, wake, check: Arc::new(check) }) + } + /// Whether a queued, preparing or live picker still owns the transport reservation. + pub fn pending(&self) -> bool { + self.busy.load(Ordering::SeqCst) + } + /// Advance requests on the event-loop thread and publish completed replies. + /// Call before consuming replies and after enqueuing work. `create` must eagerly open + /// the native picker on this thread and return its already-created future. + pub fn pump(&mut self, mut create: impl FnMut(R) -> DialogFuture) { + if let Some(state) = self.state.take() { + match state { + State::Waiting(completion) => { + if let Some(outcome) = completion.poll() { + // Only the UI pump publishes replies, after releasing the transport. + // A continuation can therefore immediately request its next picker. + self.busy.store(false, Ordering::SeqCst); + let _ = completion.answer.send(outcome); + } else { + self.state = Some(State::Waiting(completion)); + } + } + State::Preparing(preparing) => { + if let Some(outcome) = preparing.completion.poll() { + self.busy.store(false, Ordering::SeqCst); + let _ = preparing.completion.answer.send(outcome); + } else if preparing.ready.try_recv().is_ok() { + let future = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| create(preparing.request))) + .unwrap_or_else(|_| Box::pin(async { Outcome::Failed(worker_error("The file-dialog factory failed")) })); + let _ = preparing.send.send(future); + self.state = Some(State::Waiting(preparing.completion)); + } else { + self.state = Some(State::Preparing(preparing)); + } + } + } + } + if self.state.is_none() + && let Ok((request, answer)) = self.queue.try_recv() + { + let (ready_send, ready) = mpsc::channel(); + let (send, receive) = mpsc::channel::>(); + let (done_send, done) = mpsc::channel(); + let check = self.check.clone(); + let wake = self.wake.clone(); + let started = std::thread::Builder::new().name("file-dialog".into()).spawn(move || { + let output = guarded(|| { + if let Err(error) = check() { + return Outcome::Failed(error); + } + if ready_send.send(()).is_err() { + return Outcome::Failed(worker_error("The file-dialog host stopped")); + } + notify(wake.as_ref()); + match receive.recv() { + Ok(future) => block_on(future), + Err(_) => Outcome::Failed(worker_error("The file-dialog host stopped")), + } + }); + let _ = done_send.send(output); + notify(wake.as_ref()); + }); + match started { + Ok(_) => self.state = Some(State::Preparing(Preparing { request, ready, send, completion: Completion { done, answer } })), + Err(error) => { + self.busy.store(false, Ordering::SeqCst); + let _ = answer.send(Outcome::Failed(worker_error(error.to_string()))); + notify(self.wake.as_ref()); + } + } + } + } +} +/// Holds close intent while a picker/continuation owns the parent. A retry never authorizes +/// discarding documents: the caller must run its ordinary unsaved-work policy again. +#[cfg(feature = "egui")] +#[derive(Default, Debug)] +pub struct DeferredClose { + held: bool, +} +#[cfg(feature = "egui")] +impl DeferredClose { + /// Hold actual close requests while a picker or app continuation is pending. + /// Return `true` once after settlement so the host retries its unsaved-work policy; + /// this never grants permission to discard changes or closes the viewport itself. + pub fn update(&mut self, ctx: &egui::Context, pending: bool) -> bool { + let outgoing = ctx.viewport(|v| { + v.commands + .iter() + .rev() + .find_map(|c| match c { + egui::ViewportCommand::Close => Some(true), + egui::ViewportCommand::CancelClose => Some(false), + _ => None, + }) + .unwrap_or(false) + }); + if pending { + let requested = outgoing || ctx.input(|i| i.viewport().close_requested()); + self.held |= requested; + // Every viewport command requests a repaint. An idle native picker must sleep. + if requested { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + false + } else if std::mem::take(&mut self.held) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + ctx.request_repaint(); + true + } else { + false + } + } +} diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs new file mode 100644 index 00000000..b3fa54e0 --- /dev/null +++ b/crates/platform/src/lib.rs @@ -0,0 +1,11 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Shared lifecycle mechanics; application state, theme choices and file policy stay in hosts. +#[cfg(feature = "appearance")] +pub mod appearance; +#[cfg(all(feature = "browser", target_arch = "wasm32"))] +pub mod browser; +pub mod dialog_outcome; +#[cfg(all(feature = "dialogs", not(target_arch = "wasm32")))] +pub mod dialogs; +#[cfg(all(feature = "appearance", unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod process; diff --git a/crates/platform/src/process.rs b/crates/platform/src/process.rs new file mode 100644 index 00000000..24c4f7f4 --- /dev/null +++ b/crates/platform/src/process.rs @@ -0,0 +1,53 @@ +//! Bounded, cancellable output capture for the desktop's one-shot settings fallback. + +/// Reads at most 8193 bytes from a pipe, without a detached reader or disk spool. +/// Cancellation/timeout drops the reader and kills the owned child; async-process reaps it. +pub(crate) async fn capture(program: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option { + use futures_lite::io::AsyncReadExt; + use std::process::Stdio; + let mut child = async_process::Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .reap_on_drop(true) + .spawn() + .ok()?; + let stdout = child.stdout.take()?; + futures_lite::future::race( + async { + let mut bytes = Vec::new(); + stdout.take(8193).read_to_end(&mut bytes).await.ok()?; + if bytes.len() > 8192 { + return None; + } + if !child.status().await.ok()?.success() { + return None; + } + String::from_utf8(bytes).ok() + }, + async { + async_io::Timer::after(timeout).await; + None + }, + ) + .await +} +#[cfg(all(test, feature = "appearance", unix, not(target_os = "macos")))] +mod tests { + use super::capture; + #[test] + fn capture_checks_exit_status_size_and_deadline() { + use std::time::{Duration, Instant}; + futures_lite::future::block_on(async { + let shell = std::path::Path::new("/bin/sh"); + assert_eq!(capture(shell, &["-c", "printf hello"], Duration::from_secs(1)).await.as_deref(), Some("hello")); + assert_eq!(capture(shell, &["-c", "printf failure; exit 7"], Duration::from_secs(1)).await, None); + let before = Instant::now(); + assert_eq!(capture(shell, &["-c", "exec sleep 5"], Duration::from_millis(30)).await, None); + assert!(before.elapsed() < Duration::from_secs(1)); + assert_eq!(capture(shell, &["-c", "head -c 10000 /dev/zero"], Duration::from_secs(1)).await, None); + }); + } +} diff --git a/crates/platform/tests/browser_downloads.rs b/crates/platform/tests/browser_downloads.rs new file mode 100644 index 00000000..a9dd94ae --- /dev/null +++ b/crates/platform/tests/browser_downloads.rs @@ -0,0 +1,98 @@ +#![cfg(all(target_arch = "wasm32", feature = "browser"))] +use craft_platform::browser::{DownloadOptions, PreparedDownload, download_bytes}; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen(module = "/tests/browser_harness.js")] +extern "C" { + fn setup(mode: &str); + fn restore(); + fn created_count() -> u32; + fn revoked_count() -> u32; + fn clicked_count() -> u32; + fn anchor_count() -> u32; + fn last_name() -> String; + fn last_mime() -> String; + fn timer_before_click() -> bool; + fn wait_for_cleanup() -> js_sys::Promise; +} +struct Restore; +impl Drop for Restore { + fn drop(&mut self) { + restore(); + } +} + +#[wasm_bindgen_test] +fn abandoning_a_prepared_download_releases_its_url() -> Result<(), JsValue> { + setup(""); + let _restore = Restore; + let prepared = PreparedDownload::bytes("export.txt", "text/plain", b"content")?; + assert_eq!(created_count(), 1); + drop(prepared); + assert_eq!(revoked_count(), 1); + assert_eq!(clicked_count(), 0); + assert_eq!(anchor_count(), 0); + Ok(()) +} + +#[wasm_bindgen_test] +fn failed_attachment_and_timer_do_not_click_or_leak() { + for mode in ["attach-error", "timer-error"] { + setup(mode); + let _restore = Restore; + assert!(download_bytes("export.txt", "text/plain", b"content", Default::default()).is_err()); + assert_eq!(created_count(), 1); + assert_eq!(revoked_count(), 1); + assert_eq!(clicked_count(), 0); + assert_eq!(anchor_count(), 0); + } +} + +#[wasm_bindgen_test] +async fn successful_download_releases_its_url_after_the_click() -> Result<(), JsValue> { + setup(""); + let _restore = Restore; + download_bytes("export.txt", "text/plain", b"content", DownloadOptions { revoke_after_ms: 5 })?; + assert_eq!(clicked_count(), 1); + assert_eq!(last_name(), "export.txt"); + assert_eq!(last_mime(), "text/plain"); + assert!(timer_before_click()); + assert_eq!(revoked_count(), 0); + assert_eq!(anchor_count(), 0); + wasm_bindgen_futures::JsFuture::from(wait_for_cleanup()).await?; + assert_eq!(revoked_count(), 1); + Ok(()) +} + +#[wasm_bindgen_test] +fn invalid_lifetime_cleans_a_prepared_download() { + setup(""); + let _restore = Restore; + assert!(download_bytes("export.txt", "text/plain", b"content", DownloadOptions { revoke_after_ms: 0 }).is_err()); + assert_eq!(revoked_count(), 1); + assert_eq!(clicked_count(), 0); +} + +#[wasm_bindgen_test] +fn failed_setup_does_not_allocate_a_url_or_click() { + for mode in ["element-error", "style-error"] { + setup(mode); + let _restore = Restore; + assert!(download_bytes("export.txt", "text/plain", b"content", Default::default()).is_err()); + assert_eq!(created_count(), 0); + assert_eq!(revoked_count(), 0); + assert_eq!(clicked_count(), 0); + assert_eq!(anchor_count(), 0); + } +} + +#[wasm_bindgen_test] +fn empty_name_is_rejected_before_allocating_resources() { + setup(""); + let _restore = Restore; + assert!(download_bytes(" ", "text/plain", b"content", Default::default()).is_err()); + assert_eq!(created_count(), 0); + assert_eq!(clicked_count(), 0); +} diff --git a/crates/platform/tests/browser_harness.js b/crates/platform/tests/browser_harness.js new file mode 100644 index 00000000..fc652c31 --- /dev/null +++ b/crates/platform/tests/browser_harness.js @@ -0,0 +1,42 @@ +// Test-only browser fault injection; no app runtime depends on this module. +let originals; +let created, revoked, clicked, scheduled, lastName, lastMime, timerBeforeClick; +export function setup(mode) { + created = 0; revoked = 0; clicked = 0; scheduled = 0; + lastName = ''; lastMime = ''; timerBeforeClick = false; + originals = { + create: URL.createObjectURL, revoke: URL.revokeObjectURL, + click: HTMLAnchorElement.prototype.click, + append: Node.prototype.appendChild, timer: window.setTimeout, + element: Document.prototype.createElement, + attribute: Element.prototype.setAttribute, + }; + URL.createObjectURL = function(blob) { created++; lastMime = blob.type; return originals.create.call(URL, blob); }; + URL.revokeObjectURL = function(url) { revoked++; return originals.revoke.call(URL, url); }; + HTMLAnchorElement.prototype.click = function() { + clicked++; lastName = this.download; timerBeforeClick = scheduled > 0; + }; + window.setTimeout = function(...args) { + if (mode === 'timer-error') throw new Error('timer failed'); + scheduled++; + return originals.timer.apply(window, args); + }; + if (mode === 'attach-error') Node.prototype.appendChild = function() { throw new Error('attachment failed'); }; + if (mode === 'element-error') Document.prototype.createElement = function() { throw new Error('element failed'); }; + if (mode === 'style-error') Element.prototype.setAttribute = function() { throw new Error('style failed'); }; +} +export function restore() { + URL.createObjectURL = originals.create; URL.revokeObjectURL = originals.revoke; + HTMLAnchorElement.prototype.click = originals.click; + Node.prototype.appendChild = originals.append; window.setTimeout = originals.timer; + Document.prototype.createElement = originals.element; + Element.prototype.setAttribute = originals.attribute; +} +export function created_count() { return created; } +export function revoked_count() { return revoked; } +export function clicked_count() { return clicked; } +export function anchor_count() { return document.querySelectorAll('a[download]').length; } +export function last_name() { return lastName; } +export function last_mime() { return lastMime; } +export function timer_before_click() { return timerBeforeClick; } +export function wait_for_cleanup() { return new Promise(resolve => originals.timer.call(window, resolve, 50)); } diff --git a/crates/platform/tests/dialog_lifecycle.rs b/crates/platform/tests/dialog_lifecycle.rs new file mode 100644 index 00000000..1b78d031 --- /dev/null +++ b/crates/platform/tests/dialog_lifecycle.rs @@ -0,0 +1,248 @@ +#![cfg(all(feature = "dialogs", not(target_arch = "wasm32")))] +use craft_platform::{ + dialog_outcome::{DialogError, ErrorKind, Outcome}, + dialogs::{NativeDialogs, wait_prepared}, +}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, +}; +use std::time::{Duration, Instant}; +fn pump_until( + service: &mut NativeDialogs, + mut create: impl FnMut(R) -> craft_platform::dialogs::DialogFuture, + until: impl Fn() -> bool, +) { + let deadline = Instant::now() + Duration::from_secs(3); + while !until() { + assert!(Instant::now() < deadline, "worker did not settle"); + service.pump(&mut create); + std::thread::yield_now(); + } +} +#[test] +fn factory_is_eager_on_caller_and_completion_on_worker() -> Result<(), Box> { + let caller = std::thread::current().id(); + let (tx, rx) = mpsc::channel(); + let worker = wait_prepared( + || { + assert_eq!(caller, std::thread::current().id()); + async { Outcome::Selected(42) } + }, + move |outcome| { + let _ = tx.send((outcome, std::thread::current().id())); + }, + )?; + let (outcome, thread) = rx.recv_timeout(Duration::from_secs(3))?; + assert_eq!(outcome, Outcome::Selected(42)); + assert_ne!(caller, thread); + assert!(worker.join().is_ok()); + Ok(()) +} +#[test] +fn constructor_and_poll_failures_are_not_cancellation() -> Result<(), Box> { + for construct in [false, true] { + let (tx, rx) = mpsc::channel(); + let worker = wait_prepared( + || { + if construct { + std::panic::resume_unwind(Box::new("constructor")); + } + async { + std::panic::resume_unwind(Box::new("poll")); + #[allow(unreachable_code)] + Outcome::::Cancelled + } + }, + move |outcome| { + let _ = tx.send(outcome); + }, + )?; + assert!(matches!(rx.recv_timeout(Duration::from_secs(3))?, Outcome::Failed(DialogError { kind: ErrorKind::Worker, .. }))); + assert!(worker.join().is_ok()); + } + Ok(()) +} +#[test] +fn queued_and_dropped_replies_keep_live_picker_busy() -> Result<(), Box> { + let (c, mut service) = NativeDialogs::<(), u8>::new(|| {}); + let reply = c.request(()); + assert!(service.pending()); + drop(reply); + let (release, wait) = mpsc::channel(); + let mut wait = Some(wait); + let created = Arc::new(AtomicUsize::new(0)); + let count = created.clone(); + pump_until( + &mut service, + move |()| { + count.fetch_add(1, Ordering::SeqCst); + let wait = wait.take(); + Box::pin(async move { + let _ = wait.map(|w| w.recv()); + Outcome::Selected(1) + }) + }, + || created.load(Ordering::SeqCst) == 1, + ); + assert!(service.pending()); + assert!(matches!(c.request(()).recv_timeout(Duration::from_secs(3))?, Outcome::Failed(_))); + release.send(())?; + let deadline = Instant::now() + Duration::from_secs(3); + while service.pending() { + assert!(Instant::now() < deadline); + service.pump(|()| Box::pin(async { Outcome::Indeterminate })); + std::thread::yield_now(); + } + Ok(()) +} +#[test] +fn proven_absence_skips_native_factory() -> Result<(), Box> { + let (c, mut service) = NativeDialogs::<(), u8>::with_preflight(|| {}, || Err(DialogError::new(ErrorKind::Unavailable, "test backend missing"))); + let reply = c.request(()); + let called = Arc::new(AtomicUsize::new(0)); + let count = called.clone(); + let mut result = None; + let deadline = Instant::now() + Duration::from_secs(3); + while result.is_none() { + assert!(Instant::now() < deadline); + service.pump(|()| { + count.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Outcome::Selected(1) }) + }); + result = reply.try_recv().ok(); + std::thread::yield_now(); + } + assert_eq!(called.load(Ordering::SeqCst), 0); + assert!(matches!(result, Some(Outcome::Failed(DialogError { kind: ErrorKind::Unavailable, .. })))); + Ok(()) +} +#[test] +fn missing_backend_answer_is_ambiguous() { + assert_eq!(Outcome::::from_rfd(None), Outcome::Indeterminate); +} +#[cfg(feature = "egui")] +#[test] +fn held_close_retries_policy_without_granting_discard() { + let ctx = egui::Context::default(); + let mut close = craft_platform::dialogs::DeferredClose::default(); + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + let mut output = ctx.run_ui(input, |ui| assert!(!close.update(ui.ctx(), true))); + assert!(output.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::CancelClose)); + output.textures_delta.clear(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(close.update(ui.ctx(), false))); + assert!(!output.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Close)); + output.textures_delta.clear(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false))); + output.textures_delta.clear(); +} +#[test] +fn notification_panic_cannot_kill_reserved_picker_worker() -> Result<(), Box> { + let (c, mut service) = NativeDialogs::<(), u8>::new(|| std::panic::resume_unwind(Box::new("wake failure"))); + let reply = c.request(()); + let mut result = None; + let deadline = Instant::now() + Duration::from_secs(3); + while result.is_none() { + assert!(Instant::now() < deadline); + service.pump(|()| Box::pin(async { Outcome::Selected(7) })); + result = reply.try_recv().ok(); + std::thread::yield_now(); + } + assert_eq!(result, Some(Outcome::Selected(7))); + Ok(()) +} + +#[test] +fn completion_between_pumps_cannot_reject_a_chained_request() -> Result<(), Box> { + let wakes = Arc::new(AtomicUsize::new(0)); + let count = wakes.clone(); + let (client, mut service) = NativeDialogs::::new(move || { + count.fetch_add(1, Ordering::SeqCst); + }); + let first = client.request(1); + let (release, wait) = mpsc::channel(); + let mut wait = Some(wait); + let created = Arc::new(AtomicUsize::new(0)); + let count = created.clone(); + pump_until( + &mut service, + move |value| { + count.fetch_add(1, Ordering::SeqCst); + let wait = wait.take(); + Box::pin(async move { + let _ = wait.map(|wait| wait.recv()); + Outcome::Selected(value) + }) + }, + || created.load(Ordering::SeqCst) == 1, + ); + // Finish after the host's pump, before its app continuation polls the reply. + release.send(())?; + let deadline = Instant::now() + Duration::from_secs(3); + while wakes.load(Ordering::SeqCst) < 3 { + assert!(Instant::now() < deadline, "worker did not publish completion"); + std::thread::yield_now(); + } + assert!(matches!(first.try_recv(), Err(mpsc::TryRecvError::Empty))); + assert!(service.pending(), "an unpublished completion still retains the parent"); + service.pump(|value| Box::pin(async move { Outcome::Selected(value) })); + assert_eq!(first.try_recv()?, Outcome::Selected(1)); + assert!(!service.pending()); + // The app can chain the next dialog immediately upon observing the first answer. + let second = client.request(2); + assert!(matches!(second.try_recv(), Err(mpsc::TryRecvError::Empty))); + let mut result = None; + while result.is_none() { + assert!(Instant::now() < deadline, "chained picker did not settle"); + service.pump(|value| Box::pin(async move { Outcome::Selected(value) })); + result = second.try_recv().ok(); + std::thread::yield_now(); + } + assert_eq!(result, Some(Outcome::Selected(2))); + assert!(!service.pending()); + Ok(()) +} + +#[cfg(feature = "egui")] +#[test] +fn pending_dialog_without_close_intent_does_not_schedule_viewport_work() { + let ctx = egui::Context::default(); + let mut close = craft_platform::dialogs::DeferredClose::default(); + // Let egui settle its own initial font/style passes before testing the idle host. + for _ in 0..4 { + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + } + for _ in 0..3 { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(!close.update(ui.ctx(), true)); + }); + output.textures_delta.clear(); + let viewport = output.viewport_output.get(&egui::ViewportId::ROOT).unwrap(); + assert!(viewport.commands.is_empty(), "an idle picker must not enqueue commands: {:?}", viewport.commands); + assert_ne!(viewport.repaint_delay, std::time::Duration::ZERO, "an idle picker must not request another frame"); + } + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false))); + output.textures_delta.clear(); +} + +#[cfg(feature = "egui")] +#[test] +fn programmatic_close_is_held_until_the_pending_dialog_settles() { + let ctx = egui::Context::default(); + let mut close = craft_platform::dialogs::DeferredClose::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + assert!(!close.update(ui.ctx(), true)); + }); + output.textures_delta.clear(); + let commands = &output.viewport_output.get(&egui::ViewportId::ROOT).unwrap().commands; + let last_close = commands.iter().rev().find(|command| matches!(command, egui::ViewportCommand::Close | egui::ViewportCommand::CancelClose)); + assert_eq!(last_close, Some(&egui::ViewportCommand::CancelClose)); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(close.update(ui.ctx(), false))); + output.textures_delta.clear(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| assert!(!close.update(ui.ctx(), false))); + output.textures_delta.clear(); +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 25e03ad5..3c26974a 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -195,6 +195,13 @@ pub const HANDLED_OPTIONS: &[&str] = &["PageSize", "PageRegion", "Duplex", "Coll /// Caps on what a PPD may make the dialog hold (PPDs are text files from the printer vendor). const MAX_OPTIONS: usize = 400; const MAX_CHOICES: usize = 400; +/// Longest group, option or choice label kept, in characters; every option copies its group's. +const MAX_PPD_LABEL: usize = 256; + +/// `label`, cut to [`MAX_PPD_LABEL`] characters. +fn ppd_label(label: &str) -> String { + label.chars().take(MAX_PPD_LABEL).collect() +} /// A PPD keyword: printable ASCII without spaces, `/`, `:` or `=`. fn is_ppd_keyword(s: &str) -> bool { @@ -220,7 +227,7 @@ pub fn parse_ppd(ppd: &str) -> Vec { let line = line.trim_end_matches('\r'); if let Some(rest) = line.strip_prefix("*OpenGroup:") { let (k, l) = name_label(rest); - (group_key, group) = (k.to_string(), l.to_string()); + (group_key, group) = (k.to_string(), ppd_label(l)); } else if line.starts_with("*CloseGroup") { (group_key, group) = (String::new(), String::new()); } else if let Some(rest) = line.strip_prefix("*OpenUI") { @@ -228,7 +235,7 @@ pub fn parse_ppd(ppd: &str) -> Vec { let (key, label) = name_label(head.trim().trim_start_matches('*')); open = is_ppd_keyword(key).then(|| PrinterOption { key: key.to_string(), - label: label.to_string(), + label: ppd_label(label), group: group.clone(), choices: Vec::new(), default: String::new(), @@ -251,7 +258,7 @@ pub fn parse_ppd(ppd: &str) -> Vec { let head = rest.split_once(':').map_or(rest, |(h, _)| h); let (choice, label) = name_label(head); if is_ppd_keyword(choice) && o.choices.len() < MAX_CHOICES && o.choices.iter().all(|(c, _)| c != choice) { - o.choices.push((choice.to_string(), label.to_string())); + o.choices.push((choice.to_string(), ppd_label(label))); } } } @@ -413,8 +420,9 @@ pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec { let mut printers = parse_lpstat(queues); let default = default_destination(queues); if let Some(names) = available { + let mut seen: std::collections::HashSet = printers.iter().map(|p| p.name.clone()).collect(); for name in parse_lpstat_e(names) { - if !printers.iter().any(|p| p.name == name) { + if seen.insert(name.clone()) { printers.push(Printer { default: default == Some(name.as_str()), name, description: String::new(), two_sided: None }); } } diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index d3c989cd..bbb4c7c7 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -11,6 +11,7 @@ //! Every length, count and depth is bounded, and anything unreadable is an [`Error`], never a //! panic: the caller's PDF is input like any other. +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -781,16 +782,27 @@ impl Reader<'_> { None } - fn rect(&mut self, obj: Option<&Obj>) -> Option<[f64; 4]> { - let obj = self.resolve(obj?)?; - let items = obj.array()?; - let mut v = [0.0; 4]; - for (slot, item) in v.iter_mut().zip(items) { - *slot = self.resolve(item)?.num()?; + /// Like [`resolve`](Self::resolve), but a direct object is borrowed, not copied. Inherited + /// page attributes are read once per page, so copying a large inline value would repeat that + /// work for every page outside the budget. + fn resolve_borrowed<'o>(&mut self, obj: &'o Obj) -> Option> { + match obj { + Obj::Ref(..) => self.resolve(obj).map(Cow::Owned), + direct => Some(Cow::Borrowed(direct)), } + } + + fn rect(&mut self, obj: Option<&Obj>) -> Option<[f64; 4]> { + let obj = self.resolve_borrowed(obj?)?; + let items = obj.array()?; + // The shape is checked before reading any element. if items.len() != 4 { return None; } + let mut v = [0.0; 4]; + for (slot, item) in v.iter_mut().zip(items) { + *slot = self.resolve_borrowed(item)?.num()?; + } let [x0, y0, x1, y1] = v; Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]) } @@ -938,7 +950,7 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { .map(|c| [c[0].max(media[0]), c[1].max(media[1]), c[2].min(media[2]), c[3].min(media[3])]) .filter(|c| c[2] > c[0] && c[3] > c[1]) .unwrap_or(media); - let rotate = here.rotate.as_ref().and_then(|r| reader.resolve(r)).and_then(|r| r.int()).unwrap_or(0).rem_euclid(360); + let rotate = here.rotate.as_ref().and_then(|r| reader.resolve_borrowed(r).and_then(|r| r.int())).unwrap_or(0).rem_euclid(360); // Not a multiple of 90 is invalid; such a page is shown unrotated. let rotate = if rotate % 90 == 0 { u32::try_from(rotate).unwrap_or(0) } else { 0 }; // Windows renders viewer visibility, not annotation print visibility. Only a plain diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index c3675b8b..db03fb8a 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -256,6 +256,29 @@ fn printer_options_come_from_the_ppd() { assert!(spool::parse_ppd("*OpenUI *Broken\n*Broken A/B\n").is_empty(), "unclosed or malformed blocks are skipped"); } +#[test] +fn ppd_labels_are_capped_before_every_option_copies_its_group() { + let long = "G".repeat(100_000); + let mut ppd = format!("*OpenGroup: Big/{long}\n"); + for i in 0..300 { + ppd += &format!("*OpenUI *Opt{i}/{long}: PickOne\n*Opt{i} A/{long}: \"\"\n*CloseUI: *Opt{i}\n"); + } + let options = spool::parse_ppd(&ppd); + assert_eq!(options.len(), 300); + assert!(options.iter().all(|o| o.group.len() <= 256 && o.label.len() <= 256 && o.choices[0].1.len() <= 256)); + assert_eq!(options[0].group, "G".repeat(256)); +} + +#[test] +fn many_driverless_destinations_merge_in_linear_time() { + let names: String = (0..200_000).map(|i| format!("dest_{i}\n")).collect(); + let started = std::time::Instant::now(); + let printers = spool::printers_parsed("printer Office is idle.\n", Some(&format!("Office\n{names}dest_7\n"))); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + assert_eq!(printers.len(), 200_001, "each destination once, queues first"); + assert_eq!(printers[0].name, "Office"); +} + #[test] fn printer_option_defaults_and_job_arguments() { let current = diff --git a/crates/print/src/tests/pdf_bounds.rs b/crates/print/src/tests/pdf_bounds.rs index a1729273..76b1409a 100644 --- a/crates/print/src/tests/pdf_bounds.rs +++ b/crates/print/src/tests/pdf_bounds.rs @@ -254,3 +254,32 @@ fn an_inflate_limit_stops_later_stream_and_object_work() { assert_eq!(reader.budget.nodes, 0); assert_eq!(reader.budget.tokens, 0); } + +#[test] +fn an_oversized_inherited_media_box_is_not_copied_for_every_page() { + // One inline MediaBox far longer than four numbers, inherited by many small pages. + let pages = 4_000; + let media = vec!["0"; 25_000].join(" "); + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", i + 3)).collect(); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [{media}] >>", kids.join(" ")), + ]; + objects.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string())); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let started = std::time::Instant::now(); + let sheets = read(&pdf).unwrap(); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + assert_eq!(sheets.sheets.len(), pages); +} diff --git a/crates/settings/src/document.rs b/crates/settings/src/document.rs index d5c80025..4e0a16f6 100644 --- a/crates/settings/src/document.rs +++ b/crates/settings/src/document.rs @@ -36,18 +36,22 @@ pub fn new_document(version: u32) -> DocumentMut { /// is kept. This is what keeps a renamed enum variant from resetting the whole file. pub fn deserialize_sections(doc: &DocumentMut) -> (T, Vec) { let defaults = T::default(); - let Ok(mut merged) = toml_edit::ser::to_document(&defaults) else { + let Ok(base) = toml_edit::ser::to_document(&defaults) else { return (defaults, Vec::new()); }; + let mut merged = base.clone(); let mut errors = Vec::new(); for (key, item) in doc.iter() { if key == SCHEMA_VERSION_KEY { continue; } - let mut candidate = merged.clone(); + // Each section is tried against the defaults alone, never against the sections accepted + // before it, so a file of many keys costs the defaults plus that key per key instead of + // the whole document so far. + let mut candidate = base.clone(); candidate[key] = item.clone(); - match toml_edit::de::from_document::(candidate.clone()) { - Ok(_) => merged = candidate, + match toml_edit::de::from_document::(candidate) { + Ok(_) => merged[key] = item.clone(), Err(e) => errors.push(SectionError { section: key.to_owned(), message: e.to_string() }), } } diff --git a/crates/settings/tests/recovery.rs b/crates/settings/tests/recovery.rs index 4af90bb4..437d18cf 100644 --- a/crates/settings/tests/recovery.rs +++ b/crates/settings/tests/recovery.rs @@ -47,6 +47,23 @@ fn bad_value_only_resets_its_section() { assert!(config_path(tmp.path()).exists(), "the file is not moved aside"); } +#[test] +fn many_unknown_keys_load_in_linear_time() { + // Each key was once tried against every key accepted before it. + let tmp = tempfile::tempdir().unwrap(); + let mut text = String::from("schema_version = 2\n"); + for i in 0..20_000 { + text.push_str(&format!("unknown_{i} = \"{}\"\n", "x".repeat(64))); + } + text.push_str("[general]\nlanguage = \"de\"\n"); + write_config(tmp.path(), &text); + let started = std::time::Instant::now(); + let (store, report) = open_in(tmp.path()); + assert!(report.section_errors.is_empty(), "{report:?}"); + assert_eq!(store.get().general.language, "de"); + assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed()); +} + #[test] fn newer_schema_opens_read_only_and_never_overwrites() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 0b8edc2f..b30fb177 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -20,6 +20,6 @@ serde = ["dep:serde"] [dev-dependencies] egui = { workspace = true, features = ["default_fonts"] } serde_json = "1" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -egui_kittest = { version = "0.36", features = ["wgpu"] } +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +egui_kittest = { version = "0.37", features = ["wgpu"] } image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui/README.md b/crates/ui/README.md index a321c8da..f093a549 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -234,7 +234,7 @@ on WASM too; browser PDF printing remains an app-owned service. ## Compatibility and checks -The crate uses egui 0.36 and Rust 1.95 or later. Its production dependency disables egui's default +The crate uses egui 0.37 and Rust 1.95 or later. Its production dependency disables egui's default features so fonts and rendering features remain the application's choice. See the [suite audit](../../docs/ui-components-audit.md) for candidates, policies and adoption order. diff --git a/crates/ui/src/buttons/mod.rs b/crates/ui/src/buttons/mod.rs index 06708980..6865daa8 100644 --- a/crates/ui/src/buttons/mod.rs +++ b/crates/ui/src/buttons/mod.rs @@ -81,7 +81,7 @@ impl<'a> IconButton<'a> { let valid = self.size.is_finite() && self.size.x > 0.0 && self.size.y > 0.0 && self.size.x <= 4096.0 && self.size.y <= 4096.0; let (rect, response) = ui.allocate_exact_size(if valid { self.size } else { Vec2::ZERO }, if valid { Sense::click() } else { Sense::hover() }); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, valid && ui.is_enabled(), self.selected, self.label)); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, valid && ui.is_enabled(), self.selected, self.label)); if !valid { return response; } diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index babb3e2f..36c3bdbc 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -389,7 +389,13 @@ impl Layout

{ } Action::Float { panel, .. } if !permissions(panel).floatable || !permissions(panel).movable => return Err(DockError::NotAllowed), Action::Close { panel } if !permissions(panel).closable => return Err(DockError::NotAllowed), - Action::MoveFloating { panel, .. } if !permissions(panel).movable => return Err(DockError::NotAllowed), + // The rectangle belongs to the whole floating group, so every member must allow it. + Action::MoveFloating { panel, .. } + if !permissions(panel).movable + || self.floating.iter().find(|g| g.panels.contains(panel)).is_some_and(|g| g.panels.iter().any(|p| !permissions(p).movable)) => + { + return Err(DockError::NotAllowed); + } Action::Open { anchor: Some(anchor), .. } if !permissions(anchor).accepts_tabs => return Err(DockError::NotAllowed), _ => {} } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index b0e5badd..00b56b07 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -213,14 +213,14 @@ impl DockArea

{ while let Some((node, path)) = nodes.pop() { match node { Node::Tabs { panels, .. } if group(panels) => return true, - Node::Stack { entries } => { + Node::Stack { entries } if entries.iter().any(|entry| { matches(self.id.with(("tab", &entry.panel))) || matches(self.id.with(("collapse", &entry.panel))) || matches(self.id.with(("stack-resize", &entry.panel))) - }) { - return true; - } + }) => + { + return true; } Node::Split { first, second, .. } => { if matches(self.id.with(("split", &path))) { @@ -416,9 +416,8 @@ impl Renderer<'_, P> { self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect); let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); - response.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, (self.label)(&entry.panel)) - }); + response + .widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), entry.open, (self.label)(&entry.panel))); ui.painter().with_clip_rect(collapse).text( collapse.center(), egui::Align2::CENTER_CENTER, @@ -536,7 +535,7 @@ impl Renderer<'_, P> { } ui.painter().rect_filled(rect, 0.0, self.style.background); let mut child = - ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); + ui.new_child(UiBuilder::new().scope_id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); child.set_clip_rect(rect.intersect(ui.clip_rect())); self.content.body(&mut child, panel); if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) { @@ -566,7 +565,7 @@ impl Renderer<'_, P> { let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom())); let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_label)); if panels.iter().all(|p| (self.permissions)(p).movable) && response.drag_started() && let Some(first) = panels.first() @@ -597,7 +596,7 @@ impl Renderer<'_, P> { let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; if header_width > 0.0 { let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max); - let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header)); + let mut child = ui.new_child(UiBuilder::new().scope_id(self.id.with(("header", selected))).max_rect(header)); child.set_clip_rect(header.intersect(ui.clip_rect())); self.content.group_header(&mut child, panels, selected, floating); } @@ -625,7 +624,7 @@ impl Renderer<'_, P> { if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())); let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.panels_label)); ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text); egui::Popup::menu(&response).show(|ui| { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { @@ -663,7 +662,7 @@ impl Renderer<'_, P> { let logical = (self.label)(panel); let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text); let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &logical)); response } @@ -698,7 +697,7 @@ impl Renderer<'_, P> { if policy.movable && policy.floatable { let text = self.content.action_label(ui, &self.style.float_label, &self.style.font, self.style.text); let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.float_label)); if response.clicked() { let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); @@ -721,12 +720,12 @@ impl Renderer<'_, P> { } }); }); - response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); + response.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.move_label)); } if policy.closable { let text = self.content.action_label(ui, &self.style.close_label, &self.style.font, self.style.text); let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.close_label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.close_label)); if response.clicked() { self.output.actions.push(Action::Close { panel: panel.clone() }); ui.close(); @@ -766,7 +765,7 @@ impl Renderer<'_, P> { let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max); let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &self.style.resize_window_label)); if response.drag_started() { response.request_focus(); } @@ -793,7 +792,9 @@ impl Renderer<'_, P> { } }); } - if response.enabled() && delta != Vec2::ZERO { + // Resizing changes the group's shared rectangle, which the model allows only when + // every member is movable. + if response.enabled() && delta != Vec2::ZERO && group.panels.iter().all(|p| (self.permissions)(p).movable) { let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size()); self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] }); } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 54d37df6..e5841771 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -129,6 +129,21 @@ fn protected_canvas_remains_present_and_cannot_be_grouped_with_panels() { assert!(next.contains(&1) && next.contains(&7)); } +#[test] +fn moving_a_floating_group_needs_every_member_movable() { + // sample() floats 5 and 6 together; 6 may not move, so naming 5 cannot move it either. + let permissions = |panel: &u32| Permissions { movable: *panel != 6, ..Permissions::default() }; + let layout = sample(); + let mut next = layout.clone(); + assert_eq!( + next.apply_with_permissions(Action::MoveFloating { panel: 5, rect: [0.0, 0.0, 300.0, 220.0] }, permissions), + Err(DockError::NotAllowed) + ); + assert_eq!(next, layout); + next.apply_with_permissions(Action::MoveFloating { panel: 5, rect: [0.0, 0.0, 300.0, 220.0] }, |_: &u32| Permissions::default()).unwrap(); + assert_eq!(next.floating[0].rect, [0.0, 0.0, 300.0, 220.0]); +} + #[test] fn close_and_restore_preserve_fixed_side_and_floating_geometry() { let mut layout = Layout { @@ -305,14 +320,14 @@ fn frame(ctx: &Context, layout: &Layout, events: Vec, focused: bool) let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, focused, ..Default::default() }; ctx.run_ui(input, |ui| { let style = DockStyle::from_ui(ui); - output = DockArea::new(Id::new("test-area")).show( + output = DockArea::new(Id::unique("test-area")).show( ui, layout, &style, |p| format!("Panel {p}"), |_| Permissions::default(), |ui, _| { - ids.push(ui.id()); + ids.push(ui.scope_id()); ui.label("Actual panel content"); }, ); @@ -341,7 +356,7 @@ fn cancelling_or_losing_focus_during_a_drag_does_not_move_panels() { let source = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 200.0)); let press = Event::PointerButton { pos: pos2(30.0, 30.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }; frame(&ctx, &layout, vec![Event::PointerMoved(pos2(30.0, 30.0)), press], true); - assert!(begin_drag(&ctx, Id::new("test-area"), 2u32, source)); + assert!(begin_drag(&ctx, Id::unique("test-area"), 2u32, source)); let (out, _) = frame( &ctx, &layout, @@ -349,7 +364,7 @@ fn cancelling_or_losing_focus_during_a_drag_does_not_move_panels() { true, ); assert!(out.actions.is_empty()); - assert!(begin_drag(&ctx, Id::new("test-area"), 2u32, source)); + assert!(begin_drag(&ctx, Id::unique("test-area"), 2u32, source)); let (out, _) = frame(&ctx, &layout, Vec::new(), false); assert!(out.actions.is_empty()); let (out, _) = frame( @@ -381,8 +396,8 @@ fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() { for _ in 0..3 { step(&ctx, &mut layout, Vec::new()); } - let source = ctx.read_response(Id::new("test-area").with(("tab", &5u32))).unwrap().rect.center(); - let target = ctx.read_response(Id::new("test-area").with(("tab", &3u32))).unwrap().rect.center(); + let source = ctx.read_response(Id::unique("test-area").with(("tab", &5u32))).unwrap().rect.center(); + let target = ctx.read_response(Id::unique("test-area").with(("tab", &3u32))).unwrap().rect.center(); step(&ctx, &mut layout, vec![Event::PointerMoved(source), pointer_button(source, true)]); step(&ctx, &mut layout, vec![Event::PointerMoved(source + vec2(8.0, 0.0))]); step(&ctx, &mut layout, vec![Event::PointerMoved(target)]); @@ -418,7 +433,7 @@ fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() { let mut rects = std::collections::BTreeMap::new(); let mut out = DockOutput::default(); ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 600.0))), ..Default::default() }, |ui| { - out = DockArea::new(Id::new("bounds")).show_with_limits( + out = DockArea::new(Id::unique("bounds")).show_with_limits( ui, &layout, &DockStyle::from_ui(ui), @@ -460,7 +475,7 @@ fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() { for _ in 0..3 { step(&ctx, &mut layout, Vec::new()); } - let id = Id::new("test-area").with(("floating", &5u32)).with("resize"); + let id = Id::unique("test-area").with(("floating", &5u32)).with("resize"); ctx.memory_mut(|memory| memory.request_focus(id)); let key = |key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; step(&ctx, &mut layout, vec![key(egui::Key::ArrowRight), key(egui::Key::ArrowDown)]); @@ -486,7 +501,7 @@ fn stack_dividers_resize_adjacent_open_bodies_by_pointer_and_keyboard() { for _ in 0..3 { step(&ctx, &mut layout, Vec::new()); } - let id = Id::new("test-area").with(("stack-resize", &1u32)); + let id = Id::unique("test-area").with(("stack-resize", &1u32)); let start = ctx.read_response(id).unwrap().rect.center(); step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(0.0, 24.0))]); @@ -525,7 +540,7 @@ fn split_inner_edge_remains_draggable_beside_a_scrollbar() { let mut output = DockOutput::default(); let mut sidebar = Rect::NOTHING; ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { - output = DockArea::new(Id::new("scrolling-dock")).show( + output = DockArea::new(Id::unique("scrolling-dock")).show( ui, layout, &DockStyle::from_ui(ui), @@ -597,7 +612,7 @@ fn floating_only_surface_does_not_allocate_the_callers_document_region() { let layout = Layout { root: None, floating: vec![Floating { panels: vec![1u32], active: 0, rect: [30.0, 40.0, 200.0, 200.0] }] }; ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { let before = ui.available_rect_before_wrap(); - let out = DockArea::new(Id::new("float-only")).show( + let out = DockArea::new(Id::unique("float-only")).show( ui, &layout, &DockStyle::from_ui(ui), @@ -633,7 +648,7 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { for _ in 0..2 { step(&ctx, &mut layout, vec![]); } - let tab = ctx.read_response(Id::new("test-area").with(("tab", &5u32))).unwrap().rect.center(); + let tab = ctx.read_response(Id::unique("test-area").with(("tab", &5u32))).unwrap().rect.center(); step(&ctx, &mut layout, vec![Event::PointerMoved(tab), pointer_button(tab, true)]); step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(8.0, 0.0))]); step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(100.0, 100.0))]); @@ -663,7 +678,7 @@ fn host_cancel_discards_group_drag_before_workspace_replacement() { let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(moved)]); assert!(actions.iter().any(|action| matches!(action, Action::MoveFloating { .. }))); - cancel_drag::(&ctx, Id::new("test-area")); + cancel_drag::(&ctx, Id::unique("test-area")); layout = sample(); let replacement = layout.clone(); let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(moved + vec2(20.0, 10.0))]); @@ -696,7 +711,7 @@ impl DockContent for CustomContent { fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { self.headers += 1; assert!(ui.max_rect().width() <= 40.0); - ui.label("⋮"); + ui.label("…"); } } @@ -711,7 +726,7 @@ fn custom_frame( let mut out = DockOutput::default(); ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { let style = DockStyle::from_ui(ui); - let mut area = DockArea::new(Id::new("test-area")).blocked_rects(blockers); + let mut area = DockArea::new(Id::unique("test-area")).blocked_rects(blockers); if raise { area = area.raise_panel(5); } @@ -762,7 +777,7 @@ fn overlay_appearing_mid_drag_blocks_release_and_float_fallback() { let mut content = CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 }; let source = Rect::from_min_size(pos2(80.0, 70.0), vec2(300.0, 220.0)); custom_frame(&ctx, &layout, &mut content, vec![Event::PointerMoved(pos2(130.0, 82.0)), pointer_button(pos2(130.0, 82.0), true)], &[], false); - assert!(begin_drag(&ctx, Id::new("test-area"), 5, source)); + assert!(begin_drag(&ctx, Id::unique("test-area"), 5, source)); let at = pos2(700.0, 400.0); let blocker = Rect::from_min_size(pos2(650.0, 350.0), vec2(100.0, 100.0)); let out = custom_frame(&ctx, &layout, &mut content, vec![Event::PointerMoved(at), pointer_button(at, false)], &[blocker], false); @@ -801,7 +816,7 @@ fn custom_headers_and_labels_render_at_small_and_large_scales() { let mut out = DockOutput::default(); ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0 * scale, 160.0))), ..Default::default() }, |ui| { let style = DockStyle::from_ui(ui); - out = DockArea::new(Id::new("scaled")).show_customized( + out = DockArea::new(Id::unique("scaled")).show_customized( ui, &layout, &style, @@ -834,7 +849,7 @@ fn protected_group_member_blocks_whole_group_gesture_and_host_drop() { let mut out = DockOutput::default(); ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { let style = DockStyle::from_ui(ui); - out = DockArea::new(Id::new("test-area")).show( + out = DockArea::new(Id::unique("test-area")).show( ui, &layout, &style, @@ -852,17 +867,59 @@ fn protected_group_member_blocks_whole_group_gesture_and_host_drop() { } } +/// Drags the floating group's resize grip in `sample()` and returns every action emitted. +fn resize_floating_group(permissions: impl Fn(&u32) -> Permissions + Copy) -> Vec> { + let ctx = Context::default(); + let layout = sample(); + // sample() floats 5 and 6 at [80, 70, 300, 220]; the grip is its bottom-right corner. + let (start, end) = (pos2(374.0, 284.0), pos2(398.0, 308.0)); + let mut actions = Vec::new(); + for events in [ + vec![], + vec![], + vec![Event::PointerMoved(start), pointer_button(start, true)], + vec![Event::PointerMoved(end)], + vec![pointer_button(end, false)], + ] { + let mut out = DockOutput::default(); + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { + let style = DockStyle::from_ui(ui); + out = DockArea::new(Id::unique("resize-area")).show( + ui, + &layout, + &style, + |p| format!("Panel {p}"), + permissions, + |ui, _| { + ui.label("Body"); + }, + ); + }) + .textures_delta + .clear(); + actions.extend(out.actions); + } + actions +} + +#[test] +fn protected_group_member_blocks_floating_group_resize() { + let moved = |actions: &[Action]| actions.iter().any(|a| matches!(a, Action::MoveFloating { .. })); + assert!(moved(&resize_floating_group(|_| Permissions::default())), "the grip resizes a movable group"); + assert!(!moved(&resize_floating_group(|p| if *p == 6 { Permissions::PROTECTED } else { Permissions::default() }))); +} + #[test] fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid_layout() { let ctx = Context::default(); let layout = sample(); - let tab = Id::new("focus-area").with(("tab", 3u32)); - let text = Id::new("body-text"); + let tab = Id::unique("focus-area").with(("tab", 3u32)); + let text = Id::unique("body-text"); let mut value = String::new(); let mut paint = |focus: Id| { let mut output = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { - DockArea::new(Id::new("focus-area")).show( + DockArea::new(Id::unique("focus-area")).show( ui, &layout, &DockStyle::from_ui(ui), @@ -879,22 +936,22 @@ fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid output.textures_delta.clear(); }; paint(tab); - assert!(DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &layout)); + assert!(DockArea::new(Id::unique("focus-area")).chrome_has_focus(&ctx, &layout)); paint(text); - assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &layout)); + assert!(!DockArea::new(Id::unique("focus-area")).chrome_has_focus(&ctx, &layout)); paint(tab); let mut invalid = layout.clone(); invalid.floating.push(Floating { panels: vec![3], active: 0, rect: [10.0, 10.0, 100.0, 100.0] }); - assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &invalid)); - assert!(!DockArea::::new(Id::new("focus-area")).chrome_has_focus(&ctx, &Layout::default())); - ctx.memory_mut(|memory| memory.request_focus(Id::new("unrendered"))); - assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &layout)); + assert!(!DockArea::new(Id::unique("focus-area")).chrome_has_focus(&ctx, &invalid)); + assert!(!DockArea::::new(Id::unique("focus-area")).chrome_has_focus(&ctx, &Layout::default())); + ctx.memory_mut(|memory| memory.request_focus(Id::unique("unrendered"))); + assert!(!DockArea::new(Id::unique("focus-area")).chrome_has_focus(&ctx, &layout)); } #[test] fn chrome_focus_classifies_rendered_accordion_and_split_and_floating_resizers() { let ctx = Context::default(); - let area = Id::new("focus-chrome-controls"); + let area = Id::unique("focus-chrome-controls"); let accordion = Layout { root: Some(Node::Stack { entries: vec![StackEntry { panel: 1u32, open: true, height: Some(150.0) }, StackEntry { panel: 2u32, open: true, height: Some(150.0) }], @@ -930,7 +987,7 @@ fn chrome_focus_classifies_rendered_accordion_and_split_and_floating_resizers() #[test] fn workspace_cancel_stops_owned_resize_controls_until_new_press() { - let area = Id::new("test-area"); + let area = Id::unique("test-area"); let fixtures = [ (sample(), area.with(("split", Vec::::new())), vec2(24.0, 0.0)), ( @@ -978,8 +1035,8 @@ fn workspace_cancel_stops_owned_resize_controls_until_new_press() { fn workspace_cancel_preserves_other_area_and_body_drag_ownership() { for body_drag in [false, true] { let ctx = Context::default(); - let area = Id::new("cancel-area"); - let other = Id::new("other-area"); + let area = Id::unique("cancel-area"); + let other = Id::unique("other-area"); let layout = Layout { root: Some(Node::Split { axis: SplitAxis::Horizontal, @@ -989,7 +1046,7 @@ fn workspace_cancel_preserves_other_area_and_body_drag_ownership() { }), floating: vec![], }; - let body_id = Id::new("application-body-drag"); + let body_id = Id::unique("application-body-drag"); let mut value = 0.0; let mut run = |events: Vec| { let mut outputs = Vec::new(); @@ -1052,7 +1109,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { let layout = sample(); let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( move |ui, content: &mut CustomContent| { - DockArea::new(Id::new("action-label-test")).show_customized( + DockArea::new(Id::unique("action-label-test")).show_customized( ui, &layout, &DockStyle::from_ui(ui), @@ -1065,7 +1122,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 }, ); h.run_steps(2); - let tab = Id::new("action-label-test").with(("tab", &5_u32)); + let tab = Id::unique("action-label-test").with(("tab", &5_u32)); let at = h.ctx.read_response(tab).unwrap().rect.center(); h.input_mut().events.extend([ Event::PointerMoved(at), diff --git a/crates/ui/src/layout/resize.rs b/crates/ui/src/layout/resize.rs index e8a2a820..fc51aa1f 100644 --- a/crates/ui/src/layout/resize.rs +++ b/crates/ui/src/layout/resize.rs @@ -151,7 +151,7 @@ impl<'a> ResizeHandle<'a> { { egui::WidgetInfo::slider(enabled, f64::from(*value), self.label) } else { - egui::WidgetInfo::labeled(egui::WidgetType::Other, enabled, self.label) + egui::WidgetInfo::labeled(egui::Role::Unknown, enabled, self.label) } }); ui.ctx().accesskit_node_builder(response.id, |node| { diff --git a/crates/ui/src/layout/tests.rs b/crates/ui/src/layout/tests.rs index 37914652..cdd44cb5 100644 --- a/crates/ui/src/layout/tests.rs +++ b/crates/ui/src/layout/tests.rs @@ -127,7 +127,7 @@ impl HandleTest { ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }, |ui| { result = Some( ui.add_enabled_ui(self.enabled, |ui| { - ResizeHandle::new(Id::new("divider"), self.axis, self.rect, &self.label, handle_style()) + ResizeHandle::new(Id::unique("divider"), self.axis, self.rect, &self.label, handle_style()) .value(self.value, 0.0..=300.0) .show(ui) }) diff --git a/crates/ui/src/printing.rs b/crates/ui/src/printing.rs index 30d7ad4b..bdcf5527 100644 --- a/crates/ui/src/printing.rs +++ b/crates/ui/src/printing.rs @@ -1,6 +1,7 @@ //! Printer selection and asynchronous job feedback, independent of any spooler or document. //! Hosts provide workers; applications own PDF generation, commands and translated wording. +use std::collections::HashMap; use std::sync::mpsc::{Receiver, TryRecvError}; #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -19,26 +20,41 @@ pub struct Printer { /// printers show their names, which the spooler keeps distinct. pub fn shown_names(printers: &[Printer]) -> Vec { let base: Vec<&str> = printers.iter().map(|p| if p.label.trim().is_empty() { p.name.as_str() } else { p.label.as_str() }).collect(); + let mut base_count: HashMap<&str, usize> = HashMap::with_capacity(base.len()); + for text in &base { + *base_count.entry(text).or_default() += 1; + } let mut shown: Vec = printers .iter() .zip(&base) .map(|(printer, text)| { - let shared = base.iter().filter(|other| *other == text).count() > 1; + let shared = base_count.get(text).is_some_and(|n| *n > 1); if shared && *text != printer.name { format!("{text} ({})", printer.name) } else { (*text).to_owned() } }) .collect(); - // Each pass turns at least one shared text into its name, or stops: at most one per printer. - for _ in 0..printers.len() { - let shared: Vec = shown.iter().map(|text| shown.iter().filter(|other| *other == text).count() > 1).collect(); - let mut changed = false; - for ((text, printer), shared) in shown.iter_mut().zip(printers).zip(shared) { - if shared && *text != printer.name { - text.clone_from(&printer.name); - changed = true; - } + // Which printers show each text. A shared text turns every printer showing it into its name; + // a printer that shows its name never changes again, so each moves at most once and the + // whole cascade stays linear in the number of printers. + let mut by_text: HashMap> = HashMap::with_capacity(shown.len()); + for (i, text) in shown.iter().enumerate() { + by_text.entry(text.clone()).or_default().push(i); + } + let mut pending: Vec = by_text.iter().filter(|(_, ids)| ids.len() > 1).map(|(text, _)| text.clone()).collect(); + while let Some(text) = pending.pop() { + let Some(ids) = by_text.get_mut(&text) else { continue }; + if ids.len() < 2 { + continue; } - if !changed { - break; + let (stay, moving): (Vec, Vec) = ids.iter().partition(|&&i| printers.get(i).is_some_and(|p| p.name == text)); + *ids = stay; + for i in moving { + let (Some(printer), Some(slot)) = (printers.get(i), shown.get_mut(i)) else { continue }; + slot.clone_from(&printer.name); + let ids = by_text.entry(printer.name.clone()).or_default(); + ids.push(i); + if ids.len() == 2 { + pending.push(printer.name.clone()); + } } } shown diff --git a/crates/ui/src/printing/tests.rs b/crates/ui/src/printing/tests.rs index 47f3587a..a3eede0c 100644 --- a/crates/ui/src/printing/tests.rs +++ b/crates/ui/src/printing/tests.rs @@ -8,6 +8,68 @@ fn labelled(name: &str, label: &str) -> Printer { Printer { name: name.into(), label: label.into(), default: false } } +/// The pass-by-pass resolution `shown_names` replaced, to check they agree. +fn shown_names_by_passes(printers: &[Printer]) -> Vec { + let base: Vec<&str> = printers.iter().map(|p| if p.label.trim().is_empty() { p.name.as_str() } else { p.label.as_str() }).collect(); + let mut shown: Vec = printers + .iter() + .zip(&base) + .map(|(printer, text)| { + let shared = base.iter().filter(|other| *other == text).count() > 1; + if shared && *text != printer.name { format!("{text} ({})", printer.name) } else { (*text).to_owned() } + }) + .collect(); + loop { + let shared: Vec = shown.iter().map(|text| shown.iter().filter(|other| *other == text).count() > 1).collect(); + let mut changed = false; + for ((text, printer), shared) in shown.iter_mut().zip(printers).zip(shared) { + if shared && *text != printer.name { + text.clone_from(&printer.name); + changed = true; + } + } + if !changed { + return shown; + } + } +} + +/// Each printer's label is the name the one before it falls back to, so every pass of the +/// old resolution freed exactly one more printer. +fn cascade(n: usize) -> Vec { + let mut printers = vec![labelled("n0", "x"), labelled("n1", "x"), labelled("n2", "x (n0)")]; + printers.extend((3..n).map(|i| labelled(&format!("n{i}"), &format!("n{}", i - 1)))); + printers +} + +#[test] +fn collisions_resolve_like_the_pass_by_pass_rule() { + let names = ["a", "b", "c", "Office", "Office_1", "x"]; + let labels = ["", "a", "b", "Office", "Office (Office_1)", "x (a)", "a (b)", "c"]; + let mut seed = 7u32; + for _ in 0..2000 { + let printers: Vec = (0..5) + .map(|_| { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345); + labelled(names[(seed >> 8) as usize % names.len()], labels[(seed >> 16) as usize % labels.len()]) + }) + .collect(); + assert_eq!(shown_names(&printers), shown_names_by_passes(&printers), "{printers:?}"); + } + assert_eq!(shown_names(&cascade(40)), shown_names_by_passes(&cascade(40))); +} + +#[test] +fn a_long_collision_cascade_stays_fast() { + let printers = cascade(20_000); + let started = std::time::Instant::now(); + let shown = shown_names(&printers); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); + assert_eq!(shown[0], "n0"); + assert_eq!(shown[1], "x (n1)"); + assert!(shown[2..].iter().zip(&printers[2..]).all(|(s, p)| *s == p.name)); +} + #[test] fn the_dialog_shows_a_printers_label_and_keeps_its_queue_name() { // CUPS can't put dots in a queue name: the printer added as 192.0.2.15 is `_192_0_2_15`. @@ -133,7 +195,7 @@ fn discovery_and_controls_do_not_submit_a_job() { grayscale: "Graustufen", }; let ctx = egui::Context::default(); - let mut output = ctx.run_ui(egui::RawInput::default(), |ui| state.show(ui, egui::Id::new("print"), &discover, &labels)); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| state.show(ui, egui::Id::unique("print"), &discover, &labels)); output.textures_delta.clear(); assert_eq!(state.settings.printer, "Office"); assert!(state.ready()); @@ -168,7 +230,7 @@ fn localized_controls_fit_narrow_ui_and_disable_while_submitting() { let frame = |state: &mut State, events| { let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(220.0, 360.0))), events, ..Default::default() }, - |ui| state.show(ui, egui::Id::new("print"), &discover, &labels), + |ui| state.show(ui, egui::Id::unique("print"), &discover, &labels), ); output.textures_delta.clear(); output diff --git a/crates/ui/src/tabs/mod.rs b/crates/ui/src/tabs/mod.rs index e82dcca1..9dc33963 100644 --- a/crates/ui/src/tabs/mod.rs +++ b/crates/ui/src/tabs/mod.rs @@ -139,7 +139,7 @@ impl<'a> Tab<'a> { let valid = rect.is_finite() && size.x > 0.0 && size.y > 0.0 && size.x <= MAX_EXTENT && size.y <= MAX_EXTENT; let safe_rect = if valid { rect } else { Rect::from_min_size(egui::Pos2::ZERO, Vec2::ZERO) }; let response = ui.interact(safe_rect, self.id, if valid { self.sense } else { Sense::hover() }); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, valid && ui.is_enabled(), self.selected, self.label)); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, valid && ui.is_enabled(), self.selected, self.label)); ui.ctx().accesskit_node_builder(self.id, |node| { node.set_role(egui::accesskit::Role::Tab); node.clear_toggled(); diff --git a/crates/ui/src/tabs/tests.rs b/crates/ui/src/tabs/tests.rs index 6fb37e31..854cb392 100644 --- a/crates/ui/src/tabs/tests.rs +++ b/crates/ui/src/tabs/tests.rs @@ -112,7 +112,7 @@ impl TabTest { let mut output = ctx.run_ui(raw, |ui| { ui.add_enabled_ui(self.enabled, |ui| { for (i, label) in self.labels.iter().enumerate() { - let r = Tab::new(ui.id().with(("tab", i)), label, self.selected == i) + let r = Tab::new(ui.scope_id().with(("tab", i)), label, self.selected == i) .sense(self.sense) .focus_stroke(Stroke::new(1.0, Color32::LIGHT_BLUE)) .show_at(ui, self.rect.translate(vec2(i as f32 * 100.0, 0.0)), |ui, response| { diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 996c29e2..24feb6f1 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -90,14 +90,14 @@ impl Harness { let mut actions = Vec::new(); self.output = self.ctx.run_ui(input, |ui| { let style = DockStyle::from_ui(ui); - let out = DockArea::new(Id::new("workflow")).show( + let out = DockArea::new(Id::unique("workflow")).show( ui, &self.layout, &style, label, |panel| if *panel == CANVAS { Permissions::PROTECTED } else { Permissions::default() }, |ui, panel| { - self.body_ids.insert(*panel, ui.id()); + self.body_ids.insert(*panel, ui.scope_id()); if *panel == CANVAS { let rect = ui.available_rect_before_wrap(); ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(44, 53, 66)); @@ -181,7 +181,7 @@ impl Harness { } fn tab(&self, panel: u32) -> Rect { - self.ctx.read_response(Id::new("workflow").with(("tab", &panel))).unwrap().rect + self.ctx.read_response(Id::unique("workflow").with(("tab", &panel))).unwrap().rect } fn drag(&mut self, from: Pos2, to: Pos2) { diff --git a/crates/ui/tests/visual/fixtures.rs b/crates/ui/tests/visual/fixtures.rs index e9c6da21..d3b83886 100644 --- a/crates/ui/tests/visual/fixtures.rs +++ b/crates/ui/tests/visual/fixtures.rs @@ -221,7 +221,7 @@ pub fn actual(ui: &mut Ui, case: Case, mutation: Mutation) -> Vec { let response = ui .add_enabled_ui(case.state.enabled(), |ui| { ResizeHandle::new( - Id::new("visual-divider"), + Id::unique("visual-divider"), axis, split.divider, "Resize panes", @@ -252,7 +252,7 @@ pub fn actual(ui: &mut Ui, case: Case, mutation: Mutation) -> Vec { let selected = index == 0 && case.state.selected(); let response = if matches!(case.component, Component::Tab) { let rect = Rect::from_min_size(pos2(12.0 + index as f32 * (case.size().x + spacing), 12.0), case.size()); - Tab::new(Id::new(("visual-tab", index)), "Tab", selected) + Tab::new(Id::unique(("visual-tab", index)), "Tab", selected) .focus_stroke(style.focus_stroke) .show_at(ui, rect, |ui, r| tab_body(ui, r, selected, p, case.compact)) } else { diff --git a/crates/window/Cargo.toml b/crates/window/Cargo.toml new file mode 100644 index 00000000..54db833d --- /dev/null +++ b/crates/window/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "craft-window" +description = "Decide who draws the window frame on Linux (the compositor or the app) and apply it, so every Craft app gets the same native titlebar and borders per desktop." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[features] +# `apply`/`decorations`: decide decorations and set them on an `egui::ViewportBuilder`. +egui = ["dep:egui"] + +[dependencies] +egui = { workspace = true, optional = true } + +# Only Linux and the BSDs probe a Wayland compositor; everywhere else the frame is the OS's. +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] +wayland-client = "0.31" + +[lints] +workspace = true diff --git a/crates/window/README.md b/crates/window/README.md new file mode 100644 index 00000000..75b4c681 --- /dev/null +++ b/crates/window/README.md @@ -0,0 +1,70 @@ +craft-window +============ + +Decide who draws the window frame — the desktop or the app — so every Craft app shows the same +titlebar and borders on a given desktop. + +## Why + +egui apps either keep the desktop's window frame (titlebar, borders, window buttons) or turn it off +and draw their own. Across the suite this had drifted: + +- some apps always drew their own frame on Linux (so KDE's titlebar was replaced by the app's), +- some always kept the desktop's, +- some probed the compositor and only drew their own on GNOME. + +So two Craft apps open side by side on KDE could wear different titlebars. This crate makes the +decision in one place, matching each desktop. + +## The rule + +| Platform | Frame | +| --- | --- | +| macOS, Windows | desktop (the OS) | +| Linux/BSD on X11 | desktop (the window manager) | +| Linux/BSD on Wayland **with** `zxdg_decoration_manager_v1` (KDE, Sway, Hyprland, COSMIC) | desktop (the compositor) | +| Linux/BSD on Wayland **without** it (GNOME's Mutter) | the app | + +When the app draws the frame, the decision carries the window-button `Layout` the desktop names +(GNOME's `button-layout`), so the app's header bar puts minimize/maximize/close where that desktop +puts them. This crate decides *whether* and *where*; drawing the buttons stays in each app, whose +title bars differ. + +### Override + +`_TITLEBAR` forces the choice. With the default prefix `CRAFT`: `CRAFT_TITLEBAR=app` forces +the app frame, `=system` (or `=desktop`) forces the desktop frame. Apps pass their own prefix +(`Prefix::new("PDFCRAFT")` → `PDFCRAFT_TITLEBAR`) so each keeps its own variable name. + +## Use + +```rust,ignore +use craft_window::{Choice, Prefix, decorate}; + +// Once at startup, before the window is created: +let choice = Choice::probe(Prefix::new("PHOTOCRAFT")); +let viewport = decorate(egui::ViewportBuilder::default().with_title("PhotoCraft"), choice.frame()); + +// If the app draws the frame, lay the header-bar buttons out by the desktop's order: +if let Some(layout) = choice.frame().layout() { + // layout.left / layout.right -> draw Minimize/Maximize/Close there +} +``` + +An app that offers a "System title bar" preference checks `choice.overridden()` and its own setting, +and can override the probe by setting the frame to `Frame::Desktop`. + +The `egui` feature adds [`decorate`]. Without it, the crate is pure decision logic (`Choice`, +`Frame`, `Layout`) with no egui dependency. + +## Notes + +- The Wayland probe opens a short-lived connection and reads the global registry once, on its own + thread with a 500 ms timeout; on failure or timeout it keeps the desktop's frame. Call it once at + startup. +- It never touches the connection winit inherits: when `WAYLAND_SOCKET` is set (a compositor handed + the app an open descriptor), the probe skips connecting and keeps the desktop frame, so it can't + consume the socket winit needs. +- The GNOME `button-layout` lookup (`gsettings`) runs behind the same bounded worker, so a stalled + `gsettings` can't block startup either. +- `#![forbid(unsafe_code)]`, and the never-crash lints the workspace requires. diff --git a/crates/window/src/lib.rs b/crates/window/src/lib.rs new file mode 100644 index 00000000..42f936c5 --- /dev/null +++ b/crates/window/src/lib.rs @@ -0,0 +1,439 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +//! Decide who draws the window frame, so every Craft app shows the same titlebar and borders on a +//! given desktop. +//! +//! egui apps either let the desktop draw the window frame (the title bar, borders and window +//! buttons) or turn it off and draw their own. Across the suite this decision had drifted: some +//! apps always drew their own frame on Linux, some always kept the desktop's, and some probed the +//! compositor — so on KDE, say, one app wore KWin's title bar while another wore its own. This +//! crate makes the decision one place. +//! +//! The rule, by platform: +//! +//! - **macOS and Windows:** the OS always draws the frame ([`Frame::Desktop`]). (An app that insets +//! its content under the traffic lights, or draws its own caption buttons on Windows, still asks +//! the OS for a decorated window and overdraws it; that is the app's choice and not this crate's.) +//! - **Linux and the BSDs, X11:** the window manager draws the frame ([`Frame::Desktop`]). +//! - **Linux and the BSDs, Wayland:** a compositor that offers server-side decorations +//! (`zxdg_decoration_manager_v1` — KDE, Sway, Hyprland, COSMIC) draws the frame +//! ([`Frame::Desktop`]). One that does not (GNOME's Mutter) leaves the frame to the app +//! ([`Frame::App`]): without this, winit draws an imitation frame that matches no desktop. +//! +//! An environment variable overrides the choice, for users whose compositor the probe can't read +//! or who simply prefer the other frame. With the default prefix `CRAFT`, `CRAFT_TITLEBAR=app` +//! forces [`Frame::App`] and `=system` (or `=desktop`) forces [`Frame::Desktop`]; apps may pass +//! their own prefix (`PDFCRAFT`, `FIGCRAFT`, …) so each keeps its own variable name. +//! +//! When the app draws the frame, [`Frame::App`] carries the [`Layout`] of window buttons the +//! desktop names (GNOME's `button-layout`), so the app's header bar puts minimize, maximize and +//! close where that desktop puts them. This crate decides *whether* and *where*; drawing the +//! buttons stays with each app (their title bars differ). +//! +//! # With egui +//! +//! The `egui` feature adds [`decorate`], which applies the decision to an +//! [`egui::ViewportBuilder`] before the window is created: +//! +//! ```ignore +//! let viewport = egui::ViewportBuilder::default().with_title("PhotoCraft"); +//! // The app may still prefer its own frame when the user asked for it in preferences: +//! let choice = craft_window::Choice::probe(craft_window::Prefix::default()); +//! let viewport = craft_window::decorate(viewport, choice.frame()); +//! ``` +//! +//! The probe opens a short-lived Wayland connection and reads the global registry once; it never +//! touches the connection winit inherits. Call it once at startup. + +/// The environment-variable prefix an app overrides the frame with: `_TITLEBAR`. +/// +/// `Prefix::default()` is `CRAFT`, so `CRAFT_TITLEBAR`. An app that wants its own name passes +/// [`Prefix::new`] (`Prefix::new("PDFCRAFT")` reads `PDFCRAFT_TITLEBAR`). The name is used as +/// given; it should be ASCII letters, digits and underscores, as environment names are. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Prefix(&'static str); + +impl Default for Prefix { + fn default() -> Self { + Self("CRAFT") + } +} + +impl Prefix { + /// An app-specific prefix, so the override variable is `_TITLEBAR`. + pub const fn new(prefix: &'static str) -> Self { + Self(prefix) + } + + /// The environment variable this prefix reads, e.g. `CRAFT_TITLEBAR`. + pub fn var_name(self) -> String { + format!("{}_TITLEBAR", self.0) + } +} + +/// Who draws the window frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Frame { + /// The desktop draws it: the OS on macOS and Windows, the window manager on X11, the + /// compositor on a Wayland session with server-side decorations. The app leaves decorations on. + Desktop, + /// The app draws it (GNOME's Wayland session, or an override). The app turns decorations off + /// and draws a header bar with the window buttons in this [`Layout`]. + App(Layout), +} + +impl Frame { + /// Whether the app should keep the OS/compositor decorations on (`true` for [`Frame::Desktop`]). + /// This is the value to pass to `egui::ViewportBuilder::with_decorations`. + pub fn decorations(&self) -> bool { + matches!(self, Frame::Desktop) + } + + /// The window-button layout when the app draws the frame, else `None`. + pub fn layout(&self) -> Option<&Layout> { + match self { + Frame::App(layout) => Some(layout), + Frame::Desktop => None, + } + } +} + +/// How the frame was decided, so an app can log it or offer a "System title bar" preference that +/// knows the current state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Choice { + frame: Frame, + /// `Some(true)` when `_TITLEBAR` forced the app frame, `Some(false)` when it forced the + /// desktop frame, `None` when the platform decided. + overridden: Option, +} + +impl Choice { + /// Probe the running platform and desktop and decide who draws the frame. + /// + /// `prefix` names the override variable (`_TITLEBAR`). On macOS, Windows and X11 this + /// does no I/O; on a Wayland session it opens a short-lived connection to read the registry. + pub fn probe(prefix: Prefix) -> Self { + match override_frame(&prefix.var_name()) { + Some(true) => Self { frame: Frame::App(Layout::from_desktop()), overridden: Some(true) }, + Some(false) => Self { frame: Frame::Desktop, overridden: Some(false) }, + None if app_draws_frame() => Self { frame: Frame::App(Layout::from_desktop()), overridden: None }, + None => Self { frame: Frame::Desktop, overridden: None }, + } + } + + /// The decided frame. + pub fn frame(&self) -> &Frame { + &self.frame + } + + /// The decided frame, consumed. + pub fn into_frame(self) -> Frame { + self.frame + } + + /// Whether `_TITLEBAR` set the frame: `Some(true)` it forced the app's, `Some(false)` + /// the desktop's, `None` the platform decided. + pub fn overridden(&self) -> Option { + self.overridden + } +} + +/// Read ``: `app` → force the app frame, `system`/`desktop` → force the desktop frame, +/// anything else (or unset) → let the platform decide. +fn override_frame(var: &str) -> Option { + parse_override(std::env::var(var).ok().as_deref()) +} + +/// The override a `_TITLEBAR` value asks for: `Some(true)` the app frame, `Some(false)` the +/// desktop frame, `None` to let the platform decide. Pure, so it is tested without the environment. +fn parse_override(value: Option<&str>) -> Option { + match value.map(str::trim) { + Some("app") => Some(true), + Some("system" | "desktop") => Some(false), + _ => None, + } +} + +/// A window button. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Button { + Minimize, + Maximize, + Close, +} + +/// Which window buttons go on which end of the app-drawn header bar, outermost last. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Layout { + pub left: Vec