From a20bcc5b9d3212b8516355d14b6e8fda7363f0f0 Mon Sep 17 00:00:00 2001 From: Soumalya Das Date: Mon, 19 Oct 2026 21:27:57 +0530 Subject: [PATCH] feat: 0.7.0 performance hardening and latest crate upgrades --- .cargo/config.toml | 5 + .github/workflows/ci.yml | 10 + .github/workflows/release.yml | 2 +- CHANGELOG.md | 89 +++ Cargo.lock | 1044 +++++++++++++++-------------- Cargo.toml | 22 +- README.md | 2 +- wallr-common/src/cli.rs | 81 ++- wallr-common/src/config.rs | 165 ++++- wallr-common/src/effect.rs | 16 +- wallr-common/src/ipc.rs | 59 +- wallr-core/Cargo.toml | 4 +- wallr-core/src/animated/mod.rs | 238 ++++++- wallr-core/src/daemon/mod.rs | 252 +++++-- wallr-core/src/ipc/mod.rs | 472 +++++++++++-- wallr-core/src/renderer/mod.rs | 84 ++- wallr-core/src/theme/mod.rs | 158 +++-- wallr-core/src/video/decoder.rs | 140 +++- wallr-core/src/video/error.rs | 3 + wallr-core/src/video/gpu.rs | 3 +- wallr-core/src/video/playback.rs | 5 + wallr-core/src/video/scheduler.rs | 27 +- wallr-core/src/wallpaper/mod.rs | 18 + wallr/Cargo.toml | 3 + wallr/src/main.rs | 290 +++++--- 25 files changed, 2263 insertions(+), 929 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d42531f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +rustflags = ["-C", "target-cpu=native"] + +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "target-cpu=native", "-C", "link-arg=-fuse-ld=lld"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ad650f..ad586db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,13 @@ jobs: - run: cargo check --workspace - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace + - name: Verify workspace version consistency + run: | + set -euo pipefail + VERSION="$(cargo metadata --no-deps --format-version 1 | jq -re '.packages[] | select(.name=="wallr") | .version')" + for crate in wallr wallr-common wallr-core; do + crate_version="$(cargo metadata --no-deps --format-version 1 | jq -re ".packages[] | select(.name==\"$crate\") | .version")" + test "$crate_version" = "$VERSION" + done + test "$VERSION" = "0.7.0" + grep -q "## \[0.7.0\]" CHANGELOG.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 058c803..3ed7bde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag_name: - description: 'Tag name for release (e.g. v0.6.4)' + description: 'Tag name for release (e.g. v0.7.0)' required: false type: string diff --git a/CHANGELOG.md b/CHANGELOG.md index 62cb1d7..6d25d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,95 @@ All notable changes to Wallr are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/). +## [0.7.0] - 2026-10-19 + +### Added + +- `wallr-common` now reports missing environment variables while expanding + configured paths instead of silently dropping them. +- IPC sockets are now created with mode `0600`, guarded by an advisory + startup lock, and removed only when the daemon still owns the original + inode. +- IPC requests, responses, handler execution, and client connections now have + timeouts; malformed or flooding clients can no longer stall the daemon. +- GIF frame timing now aligns each graphic control extension with the next + image descriptor, including repeated or trailing extensions. +- `wallr ipc seek` accepts `HH:MM:SS`, `MM:SS`, seconds, and fractional + seconds with overflow checks. +- Regression tests cover IPC ownership, theme cache identity, duration + overflow, GIF timing, and video metadata bounds. + +### Changed + +- Theme providers, hooks, reload commands, config reload, and post-render + theme work now run on blocking workers instead of the Tokio runtime. +- External theme/hook/reload commands are killed after bounded timeouts. +- GIF decoding validates input size, frame count, screen dimensions, and frame + rectangles before allocating canvases or compositing. +- Video metadata conversion and frame copying now reject malformed dimensions + and truncated planes instead of panicking. +- `FrameScheduler` uses an offset-based clock so seeking beyond system uptime + cannot underflow. +- IPC socket defaults now use `$XDG_RUNTIME_DIR/wallr.sock` consistently. +- `DaemonConfig.auto_start` and `ThemeConfig.provider` now have serde + defaults that match their programmatic defaults. +- Static wallpaper state paths are read and written as raw bytes, preserving + non-UTF-8 paths. +- `wallr set -` stages stdin with a bounded read and private temp files. + +### Fixed + +- Duration parsing no longer panics for extreme finite values and no longer + truncates milliseconds during narrowing conversions. +- Theme cache keys now include sub-second mtime, file size, and raw path + bytes, preventing collisions within one timestamp granularity and + non-UTF-8 aliasing. +- GIF graphic control delays are no longer misaligned with image frames. +- `wallr set` no longer invokes the removed `IpcCommand::Preview` variant. +- `wallr config set` rejects unknown final config keys. +- `wallr reload`, `monitor`, and `quit` propagate daemon failures instead of + ignoring them. +- Wayland surface handles are documented and retained by `Arc` instead of + being leaked. +- `FrameScheduler::seek` no longer underflows when the requested position + exceeds elapsed runtime. + +### Security/Hardening + +- IPC sockets are mode `0600` and validated as owned sockets before reuse. +- Theme/hook/reload subprocesses have bounded runtimes. +- GIF and video input limits bound memory before expensive allocations. +- IPC concurrency is capped at 64 connections. +- Client and server IPC operations are bounded by timeouts. +- `wallr set -` limits stdin to 256 MiB. + +### Dependencies + +- Updated patch-level workspace dependencies reported by `cargo update + --dry-run`; lockfile remains reproducible. + +### Breaking Changes + +- `expand_path` now returns `Result`; callers must + handle missing environment variables. +- `start_ipc_server` now requires an `IpcSocket` reservation through + `IpcSocket::acquire` for safe ownership. +- `Daemon::new` accepts the same configuration; use `with_config_path` to + preserve an explicit config path. + +### Changed + +- MSRV bumped to 1.88; dependencies upgraded to latest compatible releases + (wgpu 30, smithay-client-toolkit 0.21, dirs 7, sha2 0.11, gif 0.14, zstd 0.14). +- Release profile now uses fat LTO, `panic = "abort"`, disabled overflow checks, + and native CPU targeting via `.cargo/config.toml`. +- `wallr` binary now uses `mimalloc` as its global allocator. + +### Performance + +- Hot paths continue to avoid redundant allocations and blocking the IPC loop; + release builds target maximum runtime performance. + ## [0.6.4] - 2026-09-30 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 42b3b3c..055025a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -62,7 +68,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -73,7 +79,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -105,20 +111,19 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bindgen" -version = "0.72.1" +version = "0.73.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cexpr", "clang-sys", - "itertools", "proc-macro2", "quote", "regex", "rustc-hash 2.1.3", - "shlex 1.3.0", - "syn 2.0.119", + "shlex", + "syn 3.0.9", ] [[package]] @@ -127,7 +132,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", ] [[package]] @@ -137,33 +151,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "bitflags" -version = "1.3.2" +name = "bit-vec" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -189,7 +194,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -206,40 +211,38 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" -version = "0.13.0" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ - "bitflags 2.13.2", - "log", + "bitflags", "polling", - "rustix 0.38.44", - "slab", - "thiserror 1.0.69", + "rustix", + "tracing", ] [[package]] name = "calloop-wayland-source" -version = "0.3.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop", - "rustix 0.38.44", + "rustix", "wayland-backend", "wayland-client", ] [[package]] name = "cc" -version = "1.4.6" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -253,9 +256,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -305,7 +308,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -321,7 +324,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ "termcolor", - "unicode-width", + "unicode-width 0.1.14", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width 0.2.2", ] [[package]] @@ -342,7 +354,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -354,38 +366,11 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -414,14 +399,19 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] @@ -432,9 +422,9 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", "crypto-common", @@ -442,9 +432,9 @@ dependencies = [ [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -458,7 +448,17 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", ] [[package]] @@ -485,17 +485,11 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -504,7 +498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -516,7 +510,7 @@ dependencies = [ "cfg-if", "document-features", "num-traits", - "thiserror 2.0.20", + "thiserror", ] [[package]] @@ -540,16 +534,16 @@ version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6380599799e175191eb7ffe82c97f36a2a90a36cbc54c738a903e5287d7f516a" dependencies = [ - "bitflags 2.13.2", + "bitflags", "ffmpeg-sys-next", "libc", ] [[package]] name = "ffmpeg-sys-next" -version = "9.0.0" +version = "9.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b939bf79dd5949412a4b81cfe21a07f48ea21b47fcbb5f57816c8c2de5ae30b" +checksum = "c6f932d8c1cede016c65d4f1435f323369c99c74df55143b18676aa10e7603f9" dependencies = [ "bindgen", "cc", @@ -561,9 +555,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -578,36 +572,9 @@ dependencies = [ [[package]] name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "futures-core" @@ -633,27 +600,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -665,16 +611,6 @@ dependencies = [ "r-efi", ] -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gif" version = "0.14.2" @@ -704,9 +640,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "glow" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", @@ -724,50 +660,38 @@ dependencies = [ ] [[package]] -name = "gpu-alloc" -version = "0.6.2" +name = "gpu-allocator" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ - "bitflags 2.13.2", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.13.2", - "gpu-descriptor-types", - "hashbrown 0.15.5", + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", ] [[package]] -name = "gpu-descriptor-types" -version = "0.2.0" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "bitflags 2.13.2", + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", ] [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -776,6 +700,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "heck" @@ -801,6 +728,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "image" version = "0.25.10" @@ -810,7 +746,7 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif 0.14.2", + "gif", "image-webp", "moxcms", "num-traits", @@ -845,15 +781,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -894,15 +821,15 @@ version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.4.3", + "getrandom", "libc", ] [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -928,9 +855,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" @@ -949,19 +876,28 @@ dependencies = [ ] [[package]] -name = "libredox" -version = "0.1.24" +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" dependencies = [ - "libc", + "cc", ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "libredox" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] [[package]] name = "linux-raw-sys" @@ -971,9 +907,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -990,15 +926,6 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "matchers" version = "0.2.0" @@ -1024,18 +951,12 @@ dependencies = [ ] [[package]] -name = "metal" -version = "0.31.0" +name = "mimalloc" +version = "0.1.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" dependencies = [ - "bitflags 2.13.2", - "block", - "core-graphics-types", - "foreign-types", - "log", - "objc", - "paste", + "libmimalloc-sys", ] [[package]] @@ -1066,13 +987,13 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1092,26 +1013,63 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" dependencies = [ "arrayvec", - "bit-set", - "bitflags 2.13.2", + "bit-set 0.8.0", + "bitflags", "cfg_aliases", - "codespan-reporting", + "codespan-reporting 0.11.1", "hexf-parse", "indexmap", "log", "rustc-hash 1.1.0", - "spirv", "strum", "termcolor", - "thiserror 2.0.20", + "thiserror", "unicode-xid", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting 0.13.1", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror", +] + [[package]] name = "ndk-sys" -version = "0.5.0+25.2.9519653" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ "jni-sys 0.3.1", ] @@ -1132,7 +1090,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1142,6 +1100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1155,12 +1114,64 @@ dependencies = [ ] [[package]] -name = "objc" -version = "0.2.7" +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "malloc_buf", + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", ] [[package]] @@ -1183,9 +1194,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" -version = "4.6.0" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +checksum = "c860fd3227ca4ac3cc032e2cd20cba3f02ccdf4b610538f8ee6584d56bb62e96" dependencies = [ "num-traits", ] @@ -1213,12 +1224,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1237,7 +1242,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.2", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -1254,10 +1259,31 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", + "rustix", + "windows-sys", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1315,24 +1341,35 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.20", + "thiserror", ] [[package]] @@ -1384,28 +1421,15 @@ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.2", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -1459,14 +1483,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -1490,9 +1514,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", @@ -1508,12 +1532,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -1553,30 +1571,30 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smithay-client-toolkit" -version = "0.19.2" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +checksum = "74dc9ee14b0fdcb535f9556141bacac070c994a977d2240ee455d7438a617f00" dependencies = [ - "bitflags 2.13.2", + "bitflags", "calloop", "calloop-wayland-source", "cursor-icon", - "libc", "log", "memmap2", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", + "rustix", + "thiserror", "wayland-client", "wayland-csd-frame", "wayland-cursor", "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", "wayland-protocols-wlr", "wayland-scanner", "xkeysym", @@ -1589,16 +1607,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "spirv" -version = "0.3.0+sdk-1.3.268.0" +version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -1648,9 +1666,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -1664,10 +1682,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", + "rustix", + "windows-sys", ] [[package]] @@ -1681,42 +1699,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1730,9 +1728,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -1741,7 +1739,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1752,7 +1750,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1761,6 +1759,7 @@ 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", @@ -1810,9 +1809,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-width" @@ -1820,6 +1819,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1852,11 +1857,12 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wallr" -version = "0.6.4" +version = "0.7.0" dependencies = [ "anyhow", "clap", "colored", + "mimalloc", "serde_yaml", "tokio", "tracing", @@ -1867,18 +1873,18 @@ dependencies = [ [[package]] name = "wallr-common" -version = "0.6.4" +version = "0.7.0" dependencies = [ "bytemuck", "clap", "serde", "serde_yaml", - "thiserror 2.0.20", + "thiserror", ] [[package]] name = "wallr-core" -version = "0.6.4" +version = "0.7.0" dependencies = [ "anyhow", "bytemuck", @@ -1887,11 +1893,11 @@ dependencies = [ "dirs", "fast_image_resize", "ffmpeg-next", - "gif 0.13.3", + "gif", "hex", "image", "libc", - "naga", + "naga 24.0.0", "raw-window-handle", "serde", "serde_json", @@ -1899,7 +1905,7 @@ dependencies = [ "sha2", "smithay-client-toolkit", "tempfile", - "thiserror 2.0.20", + "thiserror", "tokio", "tracing", "wallr-common", @@ -1918,9 +1924,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -1929,21 +1935,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1951,22 +1947,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -1979,7 +1975,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", + "rustix", "scoped-tls", "smallvec", "wayland-sys", @@ -1991,8 +1987,8 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.2", - "rustix 1.1.4", + "bitflags", + "rustix", "wayland-backend", "wayland-scanner", ] @@ -2003,7 +1999,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cursor-icon", "wayland-backend", ] @@ -2014,7 +2010,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.4", + "rustix", "wayland-client", "xcursor", ] @@ -2025,9 +2021,35 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.2", + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20251230.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c6c3e7178e553d093d46999032bc79aafea9dedd71dd702c8ace7bbc25cf13" +dependencies = [ + "bitflags", + "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", "wayland-backend", "wayland-client", + "wayland-protocols", "wayland-scanner", ] @@ -2037,7 +2059,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.2", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2063,14 +2085,15 @@ checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", + "once_cell", "pkg-config", ] [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2084,24 +2107,23 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "24.0.5" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" dependencies = [ "arrayvec", - "bitflags 2.13.2", + "bitflags", + "bytemuck", + "cfg-if", "cfg_aliases", "document-features", - "js-sys", + "hashbrown 0.17.1", "log", - "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "smallvec", "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", @@ -2109,79 +2131,124 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "24.0.5" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-vec", - "bitflags 2.13.2", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags", + "bytemuck", "cfg_aliases", "document-features", + "hashbrown 0.17.1", "indexmap", "log", - "naga", + "naga 30.0.1", + "naga-types", "once_cell", "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.20", + "thiserror", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", "wgpu-hal", + "wgpu-naga-bridge", "wgpu-types", ] +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-hal" -version = "24.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bitflags 2.13.2", + "bitflags", "bytemuck", + "cfg-if", "cfg_aliases", - "core-graphics-types", "glow", "glutin_wgl_sys", - "gpu-alloc", - "gpu-descriptor", + "gpu-allocator", + "hashbrown 0.17.1", "js-sys", "khronos-egl", "libc", "libloading", "log", - "metal", - "naga", + "naga 30.0.1", + "naga-types", "ndk-sys", - "objc", - "once_cell", + "objc2", "ordered-float", "parking_lot", + "portable-atomic", + "portable-atomic-util", "profiling", "raw-window-handle", + "raw-window-metal", "renderdoc-sys", - "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.20", + "static_assertions", + "thiserror", "wasm-bindgen", + "wayland-sys", "web-sys", + "wgpu-naga-bridge", "wgpu-types", "windows", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga 30.0.1", + "wgpu-types", ] [[package]] name = "wgpu-types" -version = "24.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" dependencies = [ - "bitflags 2.13.2", + "bitflags", + "bytemuck", "js-sys", "log", + "naga-types", + "raw-window-handle", + "static_assertions", "web-sys", ] @@ -2191,37 +2258,59 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "windows" -version = "0.58.0" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ "windows-core", - "windows-targets", ] [[package]] name = "windows-core" -version = "0.58.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", + "windows-link", "windows-result", "windows-strings", - "windows-targets", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", ] [[package]] name = "windows-implement" -version = "0.58.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -2230,9 +2319,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.58.0" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -2246,31 +2335,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-result" -version = "0.2.0" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets", + "windows-core", + "windows-link", ] [[package]] -name = "windows-strings" -version = "0.1.0" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-result", - "windows-targets", + "windows-link", ] [[package]] -name = "windows-sys" -version = "0.59.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-targets", + "windows-link", ] [[package]] @@ -2283,69 +2372,14 @@ dependencies = [ ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "windows-threading" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "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", + "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[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_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[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_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "xcursor" version = "0.3.11" @@ -2364,6 +2398,26 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -2378,27 +2432,27 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 55df1bb..b8e385b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] members = ["wallr", "wallr-common", "wallr-core"] -resolver = "2" +resolver = "3" [workspace.package] -version = "0.6.4" +version = "0.7.0" edition = "2024" authors = ["Wallr Contributors"] license = "MIT" @@ -11,7 +11,7 @@ repository = "https://github.com/programmersd21/wallr" description = "The wallpaper engine for Wayland - GPU transitions, video wallpapers, and theme pipeline integration." categories = ["command-line-utilities", "rendering"] keywords = ["wallpaper", "wayland", "wgpu", "daemon", "matugen"] -rust-version = "1.85" +rust-version = "1.88" # Optimized profile settings for faster compilation and better runtime performance [profile.dev] @@ -21,9 +21,11 @@ incremental = true # Enable incremental compilation [profile.release] opt-level = 3 # Full optimizations -lto = "thin" # Thin LTO for faster linking with good optimization +lto = "fat" # Fat LTO for maximum runtime optimization codegen-units = 1 # Better optimization, slower compile (worth it for release) strip = true # Strip symbols for smaller binary +panic = "abort" # Smaller/faster, no unwinding in release +overflow-checks = false debug = false # No debug info in release [profile.release-with-debug] @@ -36,8 +38,8 @@ strip = false opt-level = 2 # Optimize dependencies even in dev mode [workspace.dependencies] -wallr-common = { path = "wallr-common", version = "0.6.4" } -wallr-core = { path = "wallr-core", version = "0.6.4" } +wallr-common = { path = "wallr-common", version = "0.7.0" } +wallr-core = { path = "wallr-core", version = "0.7.0" } # Core utilities anyhow = "1" @@ -81,22 +83,22 @@ image = { version = "0.25", default-features = false, features = [ fast_image_resize = "6" # Graphics -wgpu = { version = "24", default-features = false, features = ["wgsl"] } +wgpu = { version = "30", default-features = false, features = ["wgsl", "vulkan", "gles"] } raw-window-handle = "0.6" # Wayland wayland-client = "0.31" wayland-protocols = { version = "0.32", features = ["client"] } -smithay-client-toolkit = { version = "0.19", default-features = false, features = [ +smithay-client-toolkit = { version = "0.21", default-features = false, features = [ "calloop", ] } wayland-backend = { version = "0.3", features = ["client_system"] } # File operations -dirs = "6" +dirs = "7" # Crypto & utilities -sha2 = { version = "0.10", default-features = false } +sha2 = { version = "0.11", default-features = false } hex = "0.4" # Video support diff --git a/README.md b/README.md index d81b0af..534ff6c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ GPU-accelerated wallpaper engine for Wayland, rendered natively with `wgpu` on ` AUR version AUR votes Crates.io version - Minimum supported Rust version + Minimum supported Rust version

diff --git a/wallr-common/src/cli.rs b/wallr-common/src/cli.rs index 6792de9..50f6b06 100644 --- a/wallr-common/src/cli.rs +++ b/wallr-common/src/cli.rs @@ -117,6 +117,64 @@ fn parse_vec2(s: &str) -> Option<[f32; 2]> { Some([x, y]) } +/// Parse an IPC seek timestamp into milliseconds. +/// +/// Accepts `SS`, `MM:SS`, or `HH:MM:SS` with optional fractional seconds for +/// the bare-seconds form. Rejects non-finite, negative, and overflowing input +/// instead of saturating or wrapping through a cast. +pub fn parse_timestamp_ms(timestamp: &str) -> Result { + let timestamp = timestamp.trim(); + if timestamp.is_empty() { + return Err("timestamp cannot be empty".to_string()); + } + if timestamp.contains(':') { + let parts: Vec<&str> = timestamp.split(':').collect(); + if !matches!(parts.len(), 2 | 3) { + return Err("invalid timestamp format. Use HH:MM:SS or seconds".to_string()); + } + let mut values = Vec::with_capacity(parts.len()); + for part in &parts { + let value: u64 = part + .parse() + .map_err(|_| format!("invalid timestamp component: {part}"))?; + values.push(value); + } + let seconds = if values.len() == 3 { + if values[1] >= 60 || values[2] >= 60 { + return Err("minutes and seconds must be below 60".to_string()); + } + values[0] + .checked_mul(3600) + .and_then(|v| v.checked_add(values[1] * 60)) + .and_then(|v| v.checked_add(values[2])) + .ok_or_else(|| "timestamp overflows".to_string())? + } else { + if values[1] >= 60 { + return Err("seconds must be below 60".to_string()); + } + values[0] + .checked_mul(60) + .and_then(|v| v.checked_add(values[1])) + .ok_or_else(|| "timestamp overflows".to_string())? + }; + seconds + .checked_mul(1000) + .ok_or_else(|| "timestamp overflows".to_string()) + } else { + let seconds: f64 = timestamp + .parse() + .map_err(|_| format!("invalid timestamp: {timestamp}"))?; + if !seconds.is_finite() || seconds < 0.0 { + return Err("timestamp must be a finite non-negative number".to_string()); + } + let millis = (seconds * 1000.0).round(); + if millis > u64::MAX as f64 { + return Err("timestamp overflows".to_string()); + } + Ok(millis as u64) + } +} + #[derive(Parser, Debug)] #[command( name = "wallr", @@ -238,8 +296,6 @@ pub enum IpcCommands { }, /// Re-render current wallpaper Reload, - /// Trigger preview transition - Preview, /// Shut down the daemon Stop, /// Query daemon status @@ -275,3 +331,24 @@ pub enum IpcCommands { effect_args: EffectArgs, }, } + +#[cfg(test)] +mod tests { + use super::parse_timestamp_ms; + + #[test] + fn parses_seek_timestamps() { + assert_eq!(parse_timestamp_ms("0").unwrap(), 0); + assert_eq!(parse_timestamp_ms("90").unwrap(), 90_000); + assert_eq!(parse_timestamp_ms("1.5").unwrap(), 1_500); + assert_eq!(parse_timestamp_ms("01:30").unwrap(), 90_000); + assert_eq!(parse_timestamp_ms("01:02:03").unwrap(), 3_723_000); + } + + #[test] + fn rejects_invalid_seek_timestamps() { + for value in ["", "-1", "NaN", "inf", "1e300", "1:60", "1:2:60", "1:2:3:4"] { + assert!(parse_timestamp_ms(value).is_err(), "{value} should fail"); + } + } +} diff --git a/wallr-common/src/config.rs b/wallr-common/src/config.rs index 13da9fe..a04b97c 100644 --- a/wallr-common/src/config.rs +++ b/wallr-common/src/config.rs @@ -83,7 +83,7 @@ pub struct MonitorConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThemeConfig { - #[serde(default)] + #[serde(default = "default_theme_provider")] pub provider: ThemeProvider, } @@ -136,7 +136,7 @@ pub struct HooksConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DaemonConfig { - #[serde(default)] + #[serde(default = "default_true")] pub auto_start: bool, #[serde(default = "default_socket")] pub socket: String, @@ -167,8 +167,12 @@ fn default_mode() -> String { fn default_scheme() -> String { "scheme-tonal-spot".to_string() } +fn default_theme_provider() -> ThemeProvider { + ThemeProvider::Matugen +} + fn default_socket() -> String { - "/tmp/wallr.sock".to_string() + "$XDG_RUNTIME_DIR/wallr.sock".to_string() } #[derive(Debug, thiserror::Error)] @@ -181,16 +185,27 @@ pub enum ConfigError { InvalidDuration(String), #[error("invalid config value: {0}")] InvalidValue(String), + #[error("environment variable {0} is not set")] + UnsetEnv(String), } pub fn load_config(path: Option<&Path>) -> Result { - let p = path.map(|p| p.to_path_buf()).unwrap_or_else(config_path); - if !p.exists() { - return Ok(apply_environment_overrides(WallrConfig::default())); + match path { + Some(path) => { + let content = std::fs::read_to_string(path)?; + let config: WallrConfig = serde_yaml::from_str(&content)?; + Ok(apply_environment_overrides(config)) + } + None => { + let path = config_path(); + if !path.exists() { + return Ok(apply_environment_overrides(WallrConfig::default())); + } + let content = std::fs::read_to_string(path)?; + let config: WallrConfig = serde_yaml::from_str(&content)?; + Ok(apply_environment_overrides(config)) + } } - let content = std::fs::read_to_string(p)?; - let config: WallrConfig = serde_yaml::from_str(&content)?; - Ok(apply_environment_overrides(config)) } /// Applies process-scoped overrides used by launchers and isolated benchmarks. @@ -205,42 +220,75 @@ fn apply_environment_overrides(mut config: WallrConfig) -> WallrConfig { config } -pub fn expand_path(path: &str) -> PathBuf { +/// Expands a leading `~`, `$VAR`, and `${VAR}` in a configured path. +/// +/// Referencing a variable that is not set is an error instead of silently +/// deleting the reference: dropping `$XDG_RUNTIME_DIR` from a socket path +/// used to turn `/run/user/1000/wallr.sock` into `/wallr.sock`, which either +/// failed with a confusing permission error or connected to the wrong path. +pub fn expand_path(path: &str) -> Result { let mut path_str = path.to_string(); - if (path_str.starts_with("~/") || path_str == "~") - && let Ok(home) = std::env::var("HOME") - { - if path_str == "~" { - path_str = home; + if path_str == "~" || path_str.starts_with("~/") { + let home = std::env::var("HOME").map_err(|_| ConfigError::UnsetEnv("HOME".into()))?; + path_str = if path_str == "~" { + home } else { - path_str = path_str.replacen("~", &home, 1); - } + path_str.replacen('~', &home, 1) + }; } let mut expanded = String::new(); let mut chars = path_str.chars().peekable(); while let Some(c) = chars.next() { - if c == '$' { - let mut env_var = String::new(); + if c != '$' { + expanded.push(c); + continue; + } + + let mut env_var = String::new(); + if chars.peek() == Some(&'{') { + chars.next(); + let mut closed = false; + for next_c in chars.by_ref() { + if next_c == '}' { + closed = true; + break; + } + if !(next_c.is_ascii_alphanumeric() || next_c == '_') { + return Err(ConfigError::InvalidValue(format!( + "invalid environment variable reference near '${{{env_var}{next_c}'" + ))); + } + env_var.push(next_c); + } + if !closed || env_var.is_empty() { + return Err(ConfigError::InvalidValue( + "unterminated or empty ${...} environment variable reference".into(), + )); + } + } else { while let Some(&next_c) = chars.peek() { - if next_c.is_alphanumeric() || next_c == '_' { + if next_c.is_ascii_alphanumeric() || next_c == '_' { env_var.push(next_c); chars.next(); } else { break; } } - if let Ok(val) = std::env::var(&env_var) { - expanded.push_str(&val); + if env_var.is_empty() { + // A literal `$` (including `$$`) is not an environment reference. + expanded.push('$'); + continue; } - } else { - expanded.push(c); } + + let value = std::env::var(&env_var).map_err(|_| ConfigError::UnsetEnv(env_var))?; + expanded.push_str(&value); } - PathBuf::from(expanded) + Ok(PathBuf::from(expanded)) } pub fn config_path() -> PathBuf { @@ -273,7 +321,10 @@ pub fn parse_duration(s: &str) -> Result { if !val.is_finite() || val < 0.0 { return Err(ConfigError::InvalidDuration(s.to_string())); } - Ok(std::time::Duration::from_secs_f64(val)) + // `from_secs_f64` panics when the value exceeds `Duration::MAX`; + // malformed or hostile config must produce an error instead. + std::time::Duration::try_from_secs_f64(val) + .map_err(|_| ConfigError::InvalidDuration(s.to_string())) } else if let Ok(val) = s.parse::() { Ok(std::time::Duration::from_millis(val)) } else { @@ -290,13 +341,29 @@ mod tests { let cfg = WallrConfig::default(); assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill); assert!(cfg.matugen.enabled); + assert!(cfg.daemon.auto_start); + assert_eq!(cfg.daemon.socket, "$XDG_RUNTIME_DIR/wallr.sock"); + assert_eq!(cfg.theme.provider, ThemeProvider::Matugen); + } + + #[test] + fn partial_daemon_section_matches_programmatic_defaults() { + let cfg: WallrConfig = serde_yaml::from_str("daemon: {max_fps: 30}\n").unwrap(); + assert!(cfg.daemon.auto_start); + assert_eq!(cfg.daemon.socket, "$XDG_RUNTIME_DIR/wallr.sock"); + assert_eq!(cfg.daemon.max_fps, Some(30)); + } + + #[test] + fn partial_theme_section_keeps_matugen_default() { + let cfg: WallrConfig = serde_yaml::from_str("theme: {}\n").unwrap(); + assert_eq!(cfg.theme.provider, ThemeProvider::Matugen); } #[test] fn test_load_config_missing_file() { - let path = Path::new("/nonexistent/config.yaml"); - let cfg = load_config(Some(path)).unwrap(); - assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill); + let path = Path::new("/nonexistent/wallr-test-config.yaml"); + assert!(load_config(Some(path)).is_err()); } #[test] @@ -307,23 +374,45 @@ mod tests { #[test] fn test_parse_duration_s() { - let dur = parse_duration("2s").unwrap(); - assert_eq!(dur.as_secs(), 2); + assert_eq!(parse_duration("2s").unwrap().as_secs(), 2); + assert_eq!(parse_duration("1.5s").unwrap().as_millis(), 1500); + } + + #[test] + fn parse_duration_rejects_extreme_and_invalid_values_without_panicking() { + for value in ["1e300s", "inf", "NaN", "-1s", "18446744073709551616ms"] { + assert!(parse_duration(value).is_err(), "{value} should be rejected"); + } } #[test] fn test_expand_path_tilde() { - let expanded = expand_path("~/test.jpg"); + let expanded = expand_path("~/test.jpg").unwrap(); assert!(!expanded.to_string_lossy().starts_with('~')); } #[test] fn test_expand_path_env() { - unsafe { - std::env::set_var("TEST_VAR", "my_folder"); - } - let expanded = expand_path("/tmp/$TEST_VAR/file.png"); - assert!(expanded.to_string_lossy().contains("my_folder")); + let expanded = expand_path("/tmp/$PATH/file.png").unwrap(); + assert!( + expanded + .to_string_lossy() + .contains(std::env::var("PATH").unwrap().as_str()) + ); + let braced = expand_path("/tmp/${PATH}/file.png").unwrap(); + assert_eq!(expanded, braced); + } + + #[test] + fn expand_path_reports_unset_and_malformed_references() { + let missing = format!("${}", "WALLR_TEST_VAR_THAT_IS_NOT_SET"); + assert!(matches!( + expand_path(&missing), + Err(ConfigError::UnsetEnv(_)) + )); + assert!(expand_path("/tmp/${/x").is_err()); + assert!(expand_path("/tmp/${}").is_err()); + assert_eq!(expand_path("/tmp/a$").unwrap(), PathBuf::from("/tmp/a$")); } #[test] @@ -332,5 +421,7 @@ mod tests { let yaml = serde_yaml::to_string(&cfg).unwrap(); let parsed: WallrConfig = serde_yaml::from_str(&yaml).unwrap(); assert_eq!(parsed.wallpaper.mode, cfg.wallpaper.mode); + assert_eq!(parsed.daemon.socket, cfg.daemon.socket); + assert_eq!(parsed.theme.provider, cfg.theme.provider); } } diff --git a/wallr-common/src/effect.rs b/wallr-common/src/effect.rs index 639af74..0a1145d 100644 --- a/wallr-common/src/effect.rs +++ b/wallr-common/src/effect.rs @@ -73,7 +73,7 @@ pub struct FadeParams { pub from: f32, #[serde(default = "default_one")] pub to: f32, - #[serde(default)] + #[serde(default = "default_easing")] pub easing: Easing, } @@ -95,7 +95,7 @@ pub struct WipeParams { pub softness: f32, #[serde(default)] pub angle: Option, - #[serde(default)] + #[serde(default = "default_easing")] pub easing: Easing, } @@ -124,7 +124,7 @@ pub enum WipeDirection { pub struct SlideParams { #[serde(default)] pub direction: SlideDirection, - #[serde(default)] + #[serde(default = "default_easing")] pub easing: Easing, } @@ -168,7 +168,7 @@ pub struct WaveParams { /// an angle derives the center from the sweep direction. #[serde(default)] pub origin: Origin, - #[serde(default)] + #[serde(default = "default_easing")] pub easing: Easing, } @@ -188,7 +188,7 @@ impl Default for WaveParams { pub struct GrowParams { #[serde(default)] pub origin: Origin, - #[serde(default)] + #[serde(default = "default_easing")] pub easing: Easing, } @@ -205,7 +205,7 @@ impl Default for GrowParams { pub struct OuterParams { #[serde(default)] pub origin: Origin, - #[serde(default)] + #[serde(default = "default_easing")] pub easing: Easing, } @@ -240,6 +240,10 @@ fn default_one() -> f32 { 1.0 } +fn default_easing() -> Easing { + Easing::Bezier +} + fn default_wipe_softness() -> f32 { 0.006 } diff --git a/wallr-common/src/ipc.rs b/wallr-common/src/ipc.rs index b027148..dca3a8a 100644 --- a/wallr-common/src/ipc.rs +++ b/wallr-common/src/ipc.rs @@ -199,14 +199,36 @@ pub fn validate_command(cmd: &IpcCommand) -> Result<(), String> { } fn effect_param_values(effect: &crate::effect::Effect) -> Vec { - use crate::effect::Effect; + use crate::effect::{Effect, Origin}; + + fn origin_values(origin: &Origin, values: &mut Vec) { + if let Origin::Custom(x, y) = origin { + values.push(*x); + values.push(*y); + } + } + + let mut values = Vec::new(); match effect { - Effect::Fade(p) => vec![p.from, p.to], - Effect::Wipe(p) => vec![p.softness, p.angle.unwrap_or(0.0)], - Effect::Slide(_) => vec![], - Effect::Wave(p) => vec![p.frequency, p.amplitude, p.angle.unwrap_or(0.0)], - Effect::Grow(_) | Effect::Outer(_) => vec![], + Effect::Fade(p) => values.extend([p.from, p.to]), + Effect::Wipe(p) => { + values.push(p.softness); + if let Some(angle) = p.angle { + values.push(angle); + } + } + Effect::Slide(_) => {} + Effect::Wave(p) => { + values.extend([p.frequency, p.amplitude]); + if let Some(angle) = p.angle { + values.push(angle); + } + origin_values(&p.origin, &mut values); + } + Effect::Grow(p) => origin_values(&p.origin, &mut values), + Effect::Outer(p) => origin_values(&p.origin, &mut values), } + values } #[cfg(test)] @@ -309,6 +331,31 @@ mod tests { ); } + #[test] + fn rejects_non_finite_effect_origins() { + for origin in [ + crate::effect::Origin::Custom(f32::NAN, 0.5), + crate::effect::Origin::Custom(0.5, f32::INFINITY), + ] { + let effect = crate::effect::Effect::Grow(crate::effect::GrowParams { + origin, + easing: crate::effect::Easing::Linear, + }); + assert!( + validate_command(&IpcCommand::Preview { + path: "/tmp/w.jpg".to_string(), + effect: Some(effect), + duration_ms: None, + no_theme: true, + theme_override: None, + monitor: None, + scaling_mode: None, + }) + .is_err() + ); + } + } + #[test] fn rejects_excessive_seek() { assert!( diff --git a/wallr-core/Cargo.toml b/wallr-core/Cargo.toml index 5afe7cb..fc74e54 100644 --- a/wallr-core/Cargo.toml +++ b/wallr-core/Cargo.toml @@ -48,8 +48,8 @@ tracing.workspace = true # Image processing image.workspace = true fast_image_resize.workspace = true -gif = "0.13" -zstd = "0.13" +gif = "0.14" +zstd = "0.14" # Graphics wgpu.workspace = true diff --git a/wallr-core/src/animated/mod.rs b/wallr-core/src/animated/mod.rs index f1f2741..08887f3 100644 --- a/wallr-core/src/animated/mod.rs +++ b/wallr-core/src/animated/mod.rs @@ -21,6 +21,12 @@ use gif::DisposalMethod; // baseline after the wallpaper is replaced. const CACHE_BUDGET: usize = 32 * 1024 * 1024; const MAX_GIF_WORKING_SET: usize = 512 * 1024 * 1024; +/// Maximum GIF file size accepted before any pixel work. A wallpaper +/// animation larger than this is almost certainly malformed or hostile, and +/// reading it whole would defeat the working-set limits below. +pub const MAX_GIF_INPUT_BYTES: usize = 256 * 1024 * 1024; +/// Bound the per-frame metadata vector as well as decoded pixels. +pub const MAX_GIF_FRAMES: usize = 100_000; /// A cached frame: raw RGBA8 or zstd-compressed RGBA8. The whole animation /// uses one representation, chosen at decode time: raw when the full decoded @@ -124,7 +130,28 @@ impl AnimatedImage { if !is_gif { return Ok(None); } - let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(std::fs::read(path)?); + let metadata = std::fs::metadata(path)?; + anyhow::ensure!( + metadata.len() <= MAX_GIF_INPUT_BYTES as u64, + "GIF file {} is {} bytes, exceeding the {} MiB input limit", + path.display(), + metadata.len(), + MAX_GIF_INPUT_BYTES / (1024 * 1024) + ); + let mut bytes = Vec::with_capacity(metadata.len() as usize); + { + use std::io::Read; + let file = std::fs::File::open(path)?; + file.take((MAX_GIF_INPUT_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + } + anyhow::ensure!( + bytes.len() <= MAX_GIF_INPUT_BYTES, + "GIF file {} grew beyond the {} MiB input limit while reading", + path.display(), + MAX_GIF_INPUT_BYTES / (1024 * 1024) + ); + let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(bytes); let Some(info) = scan_gif(&bytes)? else { return Ok(None); }; @@ -176,18 +203,39 @@ impl AnimatedImage { let Some(f) = reader.next_rgba() else { return false; }; - let (w, _h) = (self.width as usize, self.height as usize); + let (w, h) = (self.width as usize, self.height as usize); + let fw = f.width as usize; + let fh = f.height as usize; + let left = f.left as usize; + let top = f.top as usize; + + // The GIF crate decodes a frame's rectangle, but the compositor and + // canvas indexing below assume the rectangle is inside the logical + // screen. Validate before any slicing: a malformed file must fail + // safely instead of panicking the daemon. + let Some(right) = left.checked_add(fw) else { + return false; + }; + let Some(bottom) = top.checked_add(fh) else { + return false; + }; + if right > w || bottom > h || f.rgba.len() != fw.saturating_mul(fh).saturating_mul(4) { + return false; + } - if self.opaque { + let full_canvas = + left == 0 && top == 0 && fw == w && fh == h && f.rgba.len() == self.canvas.len(); + if self.opaque && full_canvas { self.canvas.copy_from_slice(&f.rgba); } else { + if self.opaque { + // The scan advertised an opaque full-canvas stream, but the + // decoder produced something else. Fall back to compositing + // for the rest of this animation. + self.opaque = false; + } // Transparency compositing: the frame's rect is blended onto the // persistent canvas, then the disposal method is applied. - let fw = f.width as usize; - let fh = f.height as usize; - let left = f.left as usize; - let top = f.top as usize; - if f.dispose == DisposalMethod::Previous { let need = fw * fh * 4; if self.prev_save.len() != need { @@ -260,10 +308,11 @@ impl AnimatedImage { } let mut guard = 0; while self.next_index <= index { - if self.next_index < self.cache.len() && self.cache[self.next_index].is_some() { - self.next_index += 1; - continue; - } + // Do not skip cached frames without consuming the decoder: the + // decoded stream position and `next_index` must stay in lockstep. + // Otherwise a later uncached tail frame is stored under the wrong + // index after a loop wrap. Re-decoding a cached prefix still uses + // the same cache entry (decode_next only inserts when absent). let before = self.next_index; if !self.decode_next() { // End of stream while seeking forward: restart from 0. @@ -311,7 +360,7 @@ impl AnimatedImage { let index = index.min(self.delays.len().saturating_sub(1)); self.ensure_upto(index); match self.cache.get(index) { - Some(Some(CachedFrame::Raw(raw))) => { + Some(Some(CachedFrame::Raw(raw))) if raw.len() == out.len() => { out.copy_from_slice(raw); true } @@ -376,6 +425,11 @@ fn gif_allocation_sizes( height: u32, frame_count: usize, ) -> anyhow::Result<(usize, usize)> { + anyhow::ensure!(width > 0 && height > 0, "GIF logical screen is empty"); + anyhow::ensure!( + frame_count <= MAX_GIF_FRAMES, + "GIF has {frame_count} frames, exceeding the {MAX_GIF_FRAMES} frame limit" + ); let pixels = usize::try_from(u64::from(width) * u64::from(height))?; let frame_bytes = pixels .checked_mul(4) @@ -400,6 +454,10 @@ fn scan_gif(bytes: &[u8]) -> anyhow::Result> { } let width = u16::from_le_bytes([bytes[6], bytes[7]]) as u32; let height = u16::from_le_bytes([bytes[8], bytes[9]]) as u32; + anyhow::ensure!( + width > 0 && height > 0, + "GIF logical screen dimensions are zero" + ); let packed = bytes[10]; let gct_size = 3 * (1 << ((packed & 0x07) + 1)); let mut pos = 13 + gct_size; @@ -410,6 +468,11 @@ fn scan_gif(bytes: &[u8]) -> anyhow::Result> { let mut delays = Vec::new(); let mut opaque = true; let mut frame_count = 0usize; + // A Graphic Control Extension applies to the next image descriptor. + // Multiple GCEs can legally precede one image; the last one wins, and a + // trailing GCE without an image must not create a phantom frame. + let mut pending_delay: Option = None; + let mut pending_transparency = false; while pos < bytes.len() { match bytes[pos] { @@ -423,6 +486,12 @@ fn scan_gif(bytes: &[u8]) -> anyhow::Result> { let top = u16::from_le_bytes([bytes[pos + 3], bytes[pos + 4]]) as u32; let iw = u16::from_le_bytes([bytes[pos + 5], bytes[pos + 6]]) as u32; let ih = u16::from_le_bytes([bytes[pos + 7], bytes[pos + 8]]) as u32; + anyhow::ensure!(iw > 0 && ih > 0, "GIF image descriptor has zero dimensions"); + anyhow::ensure!( + left.checked_add(iw).is_some_and(|right| right <= width) + && top.checked_add(ih).is_some_and(|bottom| bottom <= height), + "GIF image rectangle {left},{top} {iw}x{ih} exceeds logical screen {width}x{height}" + ); let ipacked = bytes[pos + 9]; pos += 10; if ipacked & 0x80 != 0 { @@ -436,26 +505,41 @@ fn scan_gif(bytes: &[u8]) -> anyhow::Result> { if left != 0 || top != 0 || iw != width || ih != height { opaque = false; } + if pending_transparency { + opaque = false; + } frame_count += 1; + anyhow::ensure!( + frame_count <= MAX_GIF_FRAMES, + "GIF has more than {MAX_GIF_FRAMES} frames" + ); + delays.push(pending_delay.take().unwrap_or(Duration::from_millis(20))); + pending_transparency = false; } 0x21 => { // Extension: 0xF9 = graphic control extension (delay). - if pos + 2 <= bytes.len() && bytes[pos + 1] == 0xF9 { + if pos + 2 > bytes.len() { + break; + } + if bytes[pos + 1] == 0xF9 { if pos + 8 > bytes.len() { break; } + // GCE block size must be 4 and the block terminator must + // be zero; otherwise the delay fields are not where the + // spec says they are. + anyhow::ensure!( + bytes[pos + 2] == 4 && bytes[pos + 7] == 0, + "malformed GIF graphic control extension" + ); let gce_packed = bytes[pos + 3]; - if gce_packed & 0x01 != 0 { - opaque = false; // transparency flag - } - // Delay in centiseconds; browsers treat 0 as 100ms, but - // keep the prior 20ms clamp for zero delays. let centis = u16::from_le_bytes([bytes[pos + 4], bytes[pos + 5]]); let millis = if centis == 0 { 20 } else { centis as u64 * 10 }; - delays.push( + pending_delay = Some( Duration::from_millis(millis) .clamp(Duration::from_millis(20), Duration::from_secs(5)), ); + pending_transparency = gce_packed & 0x01 != 0; pos += 8; continue; } @@ -469,10 +553,10 @@ fn scan_gif(bytes: &[u8]) -> anyhow::Result> { if frame_count == 0 { return Ok(None); } - // If no GCE appeared at all, every frame gets the default delay. - if delays.is_empty() { - delays = vec![Duration::from_millis(20); frame_count]; - } + anyhow::ensure!( + delays.len() == frame_count, + "GIF frame timing metadata is inconsistent" + ); Ok(Some(GifInfo { width, height, @@ -565,4 +649,110 @@ mod tests { assert!(gif_allocation_sizes(7_680, 4_320, 2).is_ok()); assert!(gif_allocation_sizes(16_384, 16_384, 1).is_err()); } + fn encoded_gif(frames: usize) -> Vec { + let colors: [[u8; 4]; 4] = [ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + [255, 255, 0, 255], + ]; + let palette = [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]; + let mut out = Vec::new(); + { + let mut encoder = gif::Encoder::new(&mut out, 2, 2, &palette).unwrap(); + encoder.set_repeat(gif::Repeat::Infinite).unwrap(); + for index in 0..frames { + let color = colors[index % colors.len()]; + let mut rgba = color.repeat(4); + let mut frame = gif::Frame::from_rgba_speed(2, 2, &mut rgba, 10); + frame.delay = 10; + encoder.write_frame(&frame).unwrap(); + } + } + out + } + + fn gif_with_extra_gce() -> Vec { + let mut bytes = b"GIF89a".to_vec(); + bytes.extend([2, 0, 2, 0, 0x80, 0, 0]); + bytes.extend([0, 0, 0, 255, 255, 255]); + fn gce(bytes: &mut Vec, delay: u16) { + bytes.extend([ + 0x21, + 0xF9, + 4, + 0, + (delay & 0xff) as u8, + (delay >> 8) as u8, + 0, + 0, + ]); + } + gce(&mut bytes, 10); + gce(&mut bytes, 20); // applies to the following image; the first is superseded + bytes.extend([0x2C, 0, 0, 0, 0, 2, 0, 2, 0, 0]); + bytes.extend([2, 1, 0, 0]); + gce(&mut bytes, 30); + bytes.extend([0x2C, 0, 0, 0, 0, 2, 0, 2, 0, 0]); + bytes.extend([2, 1, 0, 0]); + gce(&mut bytes, 40); // no image follows: must not become a phantom frame + bytes.push(0x3B); + bytes + } + + #[test] + fn scan_aligns_gce_delays_with_image_frames() { + let info = scan_gif(&gif_with_extra_gce()).unwrap().unwrap(); + assert_eq!(info.delays.len(), 2); + assert_eq!(info.delays[0], Duration::from_millis(200)); + assert_eq!(info.delays[1], Duration::from_millis(300)); + } + + #[test] + fn scan_rejects_out_of_bounds_and_empty_screens() { + let mut bytes = gif_with_extra_gce(); + // First image descriptor width lives at the offset after its GCE. + bytes[8] = 0; // logical width = 0 + assert!(scan_gif(&bytes).is_err()); + + let mut bytes = gif_with_extra_gce(); + // Widen the first image to 3 pixels inside a 2-pixel screen. + let descriptor = bytes + .windows(10) + .position(|window| window[0] == 0x2C) + .unwrap(); + bytes[descriptor + 5] = 3; + bytes[descriptor + 6] = 0; + assert!(scan_gif(&bytes).is_err()); + } + + #[test] + fn input_and_metadata_limits_reject_before_huge_allocations() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("huge.gif"); + let file = std::fs::File::create(&path).unwrap(); + file.set_len((MAX_GIF_INPUT_BYTES + 1) as u64).unwrap(); + assert!(AnimatedImage::decode(&path).is_err()); + assert!(gif_allocation_sizes(2, 2, MAX_GIF_FRAMES + 1).is_err()); + } + + #[test] + fn uncached_tail_after_loop_wrap_uses_the_right_frames() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("animation.gif"); + std::fs::write(&path, encoded_gif(3)).unwrap(); + + let mut expected = AnimatedImage::decode(&path).unwrap().unwrap(); + let expected_frame = expected.frame_at(2).to_vec(); + + let mut anim = AnimatedImage::decode(&path).unwrap().unwrap(); + anim.raw_cache = false; + anim.frame_at(2); // cache the whole animation as zstd + for slot in anim.cache.iter_mut().skip(1) { + *slot = None; + } + anim.cache_bytes = 0; + anim.restart(); + assert_eq!(anim.frame_at(2), expected_frame.as_slice()); + } } diff --git a/wallr-core/src/daemon/mod.rs b/wallr-core/src/daemon/mod.rs index 8bc0240..64babd2 100644 --- a/wallr-core/src/daemon/mod.rs +++ b/wallr-core/src/daemon/mod.rs @@ -1,5 +1,5 @@ use crate::config::WallrConfig; -use crate::ipc::{IpcCommand, IpcResponse, start_ipc_server}; +use crate::ipc::{IpcCommand, IpcResponse, IpcSocket, remove_socket_if_owned}; use crate::renderer::Renderer; use crate::wallpaper::{SetOptions, WallpaperEngine}; use std::sync::Arc; @@ -12,7 +12,7 @@ use raw_window_handle::{ }; use smithay_client_toolkit::{ compositor::{CompositorHandler, CompositorState}, - delegate_compositor, delegate_layer, delegate_output, delegate_registry, delegate_shm, + delegate_registry, output::{OutputHandler, OutputState}, registry::{ProvidesRegistryState, RegistryState}, registry_handlers, @@ -49,11 +49,22 @@ pub enum DaemonError { Wallpaper(#[from] crate::wallpaper::WallpaperError), } +/// Borrowed raw Wayland handles for `wgpu::Instance::create_surface`. +/// +/// The pointers reference the compositor display connection and the layer +/// surface, both owned by the daemon for at least as long as the resulting +/// render state. This type is only constructed on a blocking worker and never +/// outlives the `create_surface` call. pub struct WaylandWindow { pub display: *mut std::ffi::c_void, pub surface: *mut std::ffi::c_void, } +// SAFETY: `create_surface` requires Send + Sync on its window handle. The +// raw pointers are immutable handles to objects whose lifetime is enforced by +// `RenderState` (it holds a cloned `wl_surface` proxy and the daemon keeps the +// display connection alive). No pointer is dereferenced outside the Wayland +// client/backend while the owning connection exists. unsafe impl Send for WaylandWindow {} unsafe impl Sync for WaylandWindow {} @@ -111,10 +122,17 @@ struct WaylandState { hotplug: Option, } -/// Wrapper around `*mut c_void` that implements `Send`. The pointer is a -/// Wayland display pointer that lives for the entire process lifetime. +/// Wrapper around a raw display/surface pointer that must cross into a +/// `spawn_blocking` task or an `Arc`-shared `RenderState`. +/// +/// SAFETY: the daemon's `Connection` (display) is held for the process +/// lifetime, and each raw surface pointer is paired with a cloned +/// `wl_surface::WlSurface` proxy in `RenderState`, so the protocol object +/// cannot be destroyed while the pointer is in use. Output teardown waits for +/// the render lock and stops playback before dropping that proxy. struct SendDisplayPtr(*mut std::ffi::c_void); unsafe impl Send for SendDisplayPtr {} +unsafe impl Sync for SendDisplayPtr {} /// Shared context for hotplug operations. Stored in `WaylandState` so the /// output callbacks can create/destroy render states without needing access @@ -648,22 +666,23 @@ impl OutputHandler for WaylandState { lock.width = new_width; lock.height = new_height; if let Some(gpu) = lock.gpu.as_ref() { - if let Ok(shared) = lock.renderer.lock() { - if let Some(renderer) = shared.as_ref() { - gpu.surface.configure( - &renderer.device, - &wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format: gpu.format, - width: new_width, - height: new_height, - present_mode: wgpu::PresentMode::Fifo, - alpha_mode: wgpu::CompositeAlphaMode::Opaque, - view_formats: vec![], - desired_maximum_frame_latency: 1, - }, - ); - } + if let Ok(shared) = lock.renderer.lock() + && let Some(renderer) = shared.as_ref() + { + gpu.surface.configure( + &renderer.device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: gpu.format, + width: new_width, + height: new_height, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: wgpu::CompositeAlphaMode::Opaque, + view_formats: vec![], + color_space: wgpu::SurfaceColorSpace::default(), + desired_maximum_frame_latency: 1, + }, + ); } tracing::info!( "Hotplug: reconfigured {name} to {new_width}x{new_height}" @@ -739,11 +758,9 @@ impl OutputHandler for WaylandState { } } -delegate_compositor!(WaylandState); -delegate_layer!(WaylandState); -delegate_output!(WaylandState); delegate_registry!(WaylandState); -delegate_shm!(WaylandState); + +smithay_client_toolkit::delegate_dispatch2!(WaylandState); /// Wakes paced live-playback loops when a new commit bumps the generation. struct LivePacer { @@ -862,7 +879,9 @@ struct RenderState { struct GpuState { renderer: std::sync::Arc, - surface: &'static wgpu::Surface<'static>, + /// Owned, not leaked: output hotplug must release the swapchain when the + /// monitor disappears. Detached render tasks clone the `Arc`. + surface: std::sync::Arc>, format: wgpu::TextureFormat, per_output_uniforms: std::sync::Arc, } @@ -944,13 +963,25 @@ impl RenderState { present_mode: wgpu::PresentMode::Fifo, alpha_mode: wgpu::CompositeAlphaMode::Opaque, view_formats: vec![], + color_space: wgpu::SurfaceColorSpace::default(), desired_maximum_frame_latency: 1, }, ); // SAFETY: the Wayland display and layer surface are retained by the - // daemon for the lifetime of this render state. + // daemon for the lifetime of this render state. `RenderState` owns a + // cloned `wl_surface` proxy and output teardown waits for the render + // lock before dropping it, so extending the borrow to 'static is + // valid. Storing the surface by value (instead of leaking it) lets a + // disconnected output release the swapchain. + // SAFETY: `create_surface` borrows the Wayland display/surface for + // the call; the daemon retains both for the lifetime of this render + // state (the `Connection` lives until process exit and the cloned + // `wl_surface` proxy is stored in `RenderState`). Extending the + // borrow to `'static` is therefore sound. The `Surface` is later + // released by dropping the `Arc`, which hands the swapchain back to + // wgpu; it is not leaked. let wgpu_surface: wgpu::Surface<'static> = unsafe { std::mem::transmute(wgpu_surface) }; - let surface: &'static wgpu::Surface<'static> = Box::leak(Box::new(wgpu_surface)); + let surface = std::sync::Arc::new(wgpu_surface); self.gpu = Some(GpuState { renderer: renderer.clone(), surface, @@ -1146,8 +1177,10 @@ impl RenderState { .ok_or_else(|| anyhow::anyhow!("shared-memory wallpaper buffer is still active"))?; for (src, dst) in rgba .as_raw() - .chunks_exact(4) - .zip(canvas.chunks_exact_mut(4)) + .as_chunks::<4>() + .0 + .iter() + .zip(canvas.as_chunks_mut::<4>().0.iter_mut()) { // XRGB8888 is stored as B,G,R,X on little-endian Wayland hosts. // Write channels directly so the hot conversion loop does not @@ -1370,7 +1403,7 @@ impl RenderState { .as_ref() .expect("GPU state required for transitions"); let renderer = gpu.renderer.clone(); - let surface: &'static wgpu::Surface<'static> = gpu.surface; + let surface = std::sync::Arc::clone(&gpu.surface); let render_lock = self.render_lock.clone(); let playback_gen = self.playback_gen.clone(); let pacer = self.pacer.clone(); @@ -1598,7 +1631,7 @@ fn persist_wallpaper_at( #[allow(clippy::too_many_arguments)] fn render_transition( renderer: std::sync::Arc, - surface: &'static wgpu::Surface<'static>, + surface: std::sync::Arc>, render_lock: std::sync::Arc>, playback_gen: std::sync::Arc, pacer: std::sync::Arc, @@ -1635,7 +1668,7 @@ fn render_transition( let uniforms = crate::animation::compute_effect_uniforms(&effect, progress.clamp(0.0, 1.0)); let status = renderer.render_frame( crate::renderer::FrameRequest { - surface, + surface: &surface, format: commit.format, bg_bind: &commit.bg_bind, new_bind: &commit.new_bind, @@ -1682,6 +1715,7 @@ fn render_transition( present_mode: wgpu::PresentMode::Fifo, alpha_mode: wgpu::CompositeAlphaMode::Opaque, view_formats: vec![], + color_space: wgpu::SurfaceColorSpace::default(), desired_maximum_frame_latency: 1, }, ); @@ -1698,7 +1732,7 @@ fn render_transition( { play_live( &renderer, - surface, + &surface, &commit, animated, &playback_gen, @@ -1709,7 +1743,7 @@ fn render_transition( } else if commit.is_video && playback_gen.load(Ordering::SeqCst) == commit.generation { play_video( &renderer, - surface, + &surface, &commit, &video_playback, &playback_gen, @@ -1725,7 +1759,10 @@ fn render_transition( fn reclaim_commit_resources(renderer: &Renderer, commit: CommitData) { drop(commit); - renderer.device.poll(wgpu::Maintain::Wait); + renderer + .device + .poll(wgpu::PollType::wait_indefinitely()) + .ok(); } /// GIF double-buffer slot advance. @@ -1764,7 +1801,7 @@ fn advance_gif_slots( #[allow(clippy::too_many_arguments)] fn play_live( renderer: &Renderer, - surface: &'static wgpu::Surface<'static>, + surface: &wgpu::Surface<'static>, commit: &CommitData, animated: &mut crate::animated::AnimatedImage, playback_gen: &std::sync::atomic::AtomicU64, @@ -1830,11 +1867,17 @@ fn play_live( let buffer = &staging[slot]; let slice = buffer.slice(..); slice.map_async(wgpu::MapMode::Write, |_| {}); - renderer.device.poll(wgpu::Maintain::Wait); - let ok = { - let mut mapped = slice.get_mapped_range_mut(); - animated.decompress_into(index, &mut mapped) - }; + renderer + .device + .poll(wgpu::PollType::wait_indefinitely()) + .ok(); + let size = slice.size() as usize; + let mut tmp = vec![0u8; size]; + let ok = animated.decompress_into(index, &mut tmp); + if ok { + let mut mapped = slice.get_mapped_range_mut().unwrap(); + mapped.copy_from_slice(&tmp); + } buffer.unmap(); if ok { let mut encoder = renderer @@ -1969,6 +2012,7 @@ fn play_live( present_mode: wgpu::PresentMode::Fifo, alpha_mode: wgpu::CompositeAlphaMode::Opaque, view_formats: vec![], + color_space: wgpu::SurfaceColorSpace::default(), desired_maximum_frame_latency: 1, }, ); @@ -2024,7 +2068,7 @@ fn video_present_action(status: crate::renderer::FrameStatus) -> VideoPresentAct /// Live video playback loop: continuously updates texture with decoded frames. fn play_video( renderer: &Renderer, - surface: &'static wgpu::Surface<'static>, + surface: &wgpu::Surface<'static>, commit: &CommitData, video_playback: &std::sync::Arc, playback_gen: &std::sync::atomic::AtomicU64, @@ -2174,6 +2218,7 @@ fn play_video( present_mode: wgpu::PresentMode::Fifo, alpha_mode: wgpu::CompositeAlphaMode::Opaque, view_formats: vec![], + color_space: wgpu::SurfaceColorSpace::default(), desired_maximum_frame_latency: 1, }, ); @@ -2370,15 +2415,27 @@ fn tune_allocator() { pub struct Daemon { config: WallrConfig, + /// Explicit `--config` path, if any. Reload must re-read the same file + /// the daemon was started with instead of silently falling back to the + /// default location. + config_path: Option, paused: Arc, engine: Arc>, } impl Daemon { pub fn new(config: WallrConfig) -> Result { + Self::with_config_path(config, None) + } + + pub fn with_config_path( + config: WallrConfig, + config_path: Option, + ) -> Result { let engine = WallpaperEngine::new(config.clone())?; Ok(Self { config, + config_path, paused: Arc::new(AtomicBool::new(false)), engine: Arc::new(Mutex::new(engine)), }) @@ -2386,15 +2443,18 @@ impl Daemon { pub async fn start(self) -> Result<(), DaemonError> { tune_allocator(); - let socket_path = crate::config::expand_path(&self.config.daemon.socket); - if socket_path.exists() { - if tokio::net::UnixStream::connect(&socket_path).await.is_ok() { - return Err(DaemonError::AlreadyRunning( - socket_path.to_string_lossy().to_string(), - )); + let socket_path = crate::config::expand_path(&self.config.daemon.socket)?; + // Reserve the socket before spending seconds on Wayland/GPU setup. + // The advisory lock plus inode-checked guard prevents concurrent + // startup from unlinking a live daemon's socket. + let socket = IpcSocket::acquire(&socket_path).await.map_err(|err| { + if matches!(err, crate::ipc::IpcError::AlreadyRunning(_)) { + DaemonError::AlreadyRunning(socket_path.to_string_lossy().to_string()) + } else { + DaemonError::Ipc(err) } - let _ = std::fs::remove_file(&socket_path); - } + })?; + let socket_identity = socket.identity(); let conn = Connection::connect_to_env() .map_err(|e| DaemonError::StartError(format!("Failed to connect to Wayland: {e:?}")))?; @@ -2564,9 +2624,15 @@ impl Daemon { let socket_path = socket_path.clone(); tokio::spawn(async move { use tokio::signal::unix::{SignalKind, signal}; - let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler"); - let mut int = signal(SignalKind::interrupt()).expect("SIGINT handler"); - let mut hup = signal(SignalKind::hangup()).expect("SIGHUP handler"); + let Ok(mut term) = signal(SignalKind::terminate()) else { + return; + }; + let Ok(mut int) = signal(SignalKind::interrupt()) else { + return; + }; + let Ok(mut hup) = signal(SignalKind::hangup()) else { + return; + }; tokio::select! { _ = term.recv() => {} _ = int.recv() => {} @@ -2580,18 +2646,21 @@ impl Daemon { } } drop(states); - let _ = std::fs::remove_file(&socket_path); + remove_socket_if_owned(&socket_path, socket_identity); std::process::exit(0); }); } let ipc_socket_path = socket_path.clone(); - start_ipc_server(&socket_path, move |cmd| { + let reload_config_path = self.config_path.clone(); + socket.spawn(move |cmd| { let paused = paused_clone.clone(); let engine = engine_clone.clone(); let render_states_map = render_states_clone.clone(); let theme_gen = theme_gen_clone.clone(); let stop_socket = ipc_socket_path.clone(); + let stop_identity = socket_identity; + let reload_config_path = reload_config_path.clone(); async move { match cmd { IpcCommand::Pause { monitor } => { @@ -2643,20 +2712,30 @@ impl Daemon { // Re-read config from disk, validate, and apply live // values without rebuilding GPU state. Invalid configs // keep the previous valid configuration. - let fresh = crate::config::load_config(None) - .map_err(|e| e.to_string()) - .and_then(|cfg| { - validate_live_config(&cfg)?; - Ok(cfg) - }); + let reload_path = reload_config_path.clone(); + let fresh = tokio::task::spawn_blocking(move || { + crate::config::load_config(reload_path.as_deref()) + .map_err(|e| e.to_string()) + .and_then(|cfg| { + validate_live_config(&cfg)?; + Ok(cfg) + }) + }) + .await; let fresh = match fresh { - Ok(cfg) => cfg, - Err(reason) => { + Ok(Ok(cfg)) => cfg, + Ok(Err(reason)) => { return IpcResponse { success: false, message: Some(format!("Reload rejected: {reason}")), }; } + Err(err) => { + return IpcResponse { + success: false, + message: Some(format!("Reload task failed: {err}")), + }; + } }; let preload = clamp_preload_frames(fresh.video.preload_frames); let max_fps = clamp_max_fps(fresh.daemon.max_fps); @@ -2665,12 +2744,26 @@ impl Daemon { { let mut eng = engine.lock().await; eng.config = fresh.clone(); - if let Err(e) = eng.reload() { + } + let reload_config = fresh.clone(); + let reload_result = tokio::task::spawn_blocking(move || { + WallpaperEngine::new(reload_config)?.reload_blocking() + }) + .await; + match reload_result { + Ok(Ok(())) => {} + Ok(Err(e)) => { return IpcResponse { success: false, message: Some(e.to_string()), }; } + Err(e) => { + return IpcResponse { + success: false, + message: Some(format!("reload task failed: {e}")), + }; + } } // Apply video params per output without holding the // map lock across awaits. @@ -2785,19 +2878,31 @@ impl Daemon { theme_provider: theme_override, monitor, }; - let eng = engine.clone(); let theme_gen_check = theme_gen.clone(); let p_clone = p.clone(); - tokio::spawn(async move { - if theme_gen_check.load(Ordering::SeqCst) != generation { - return; - } - let mut eng = eng.lock().await; + // Never run hooks or theme providers on the + // async runtime: they are synchronous + // subprocess waits that previously froze the + // current-thread daemon for their duration. + let config = { + let eng = engine.lock().await; + eng.config.clone() + }; + tokio::task::spawn_blocking(move || { if theme_gen_check.load(Ordering::SeqCst) != generation { return; } + let mut eng = match WallpaperEngine::new(config) { + Ok(engine) => engine, + Err(err) => { + tracing::warn!( + "Post-render theme setup failed for {p_clone:?}: {err}" + ); + return; + } + }; if let Err(e) = - eng.set_wallpaper(&p_clone, &opts).await + eng.set_wallpaper_blocking(&p_clone, &opts) { tracing::warn!( "Post-render hooks/theme failed for {p_clone:?}: {e}" @@ -2822,7 +2927,7 @@ impl Daemon { let sp = stop_socket.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(300)).await; - let _ = std::fs::remove_file(&sp); + remove_socket_if_owned(&sp, stop_identity); std::process::exit(0); }); IpcResponse { @@ -3212,8 +3317,7 @@ impl Daemon { } } } - }) - .await?; + }); tokio::task::spawn_blocking(move || { loop { diff --git a/wallr-core/src/ipc/mod.rs b/wallr-core/src/ipc/mod.rs index 398b521..a999dc9 100644 --- a/wallr-core/src/ipc/mod.rs +++ b/wallr-core/src/ipc/mod.rs @@ -3,98 +3,452 @@ pub use wallr_common::ipc::*; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::Semaphore; + +/// Maximum response accepted from a daemon. A status/`info` response grows +/// with the output count but is still far below this bound. +pub const MAX_IPC_RESPONSE_BYTES: usize = 1024 * 1024; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const WRITE_TIMEOUT: Duration = Duration::from_secs(5); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(120); +const HANDLER_TIMEOUT: Duration = Duration::from_secs(120); +const MAX_CONCURRENT_CONNECTIONS: usize = 64; #[derive(Debug, thiserror::Error)] pub enum IpcError { #[error("daemon not running: {0}")] DaemonNotRunning(String), + #[error("daemon already running: {0}")] + AlreadyRunning(String), + #[error( + "refusing to use IPC socket path {0}: it exists but is not a socket owned by this user" + )] + UnsafeSocketPath(String), + #[error("IPC request/response timed out: {0}")] + Timeout(String), + #[error("IPC response exceeds {MAX_IPC_RESPONSE_BYTES} bytes")] + ResponseTooLarge, #[error("IPC I/O error: {0}")] Io(#[from] std::io::Error), #[error("protocol serialization/deserialization error: {0}")] Protocol(#[from] serde_json::Error), } +/// An exclusively owned, bound Unix-domain IPC socket. +/// +/// The advisory lock file serializes daemon startup so two processes can +/// never both decide that the socket is stale. The guard removes the socket +/// on drop, but only when the path still refers to the exact inode created +/// by this instance, so a successor's socket is never unlinked. +pub struct IpcSocket { + path: PathBuf, + listener: UnixListener, + dev: u64, + ino: u64, + _lock: std::fs::File, +} + +impl IpcSocket { + /// Acquires the socket path and binds a listener. + /// + /// A live socket is reported as [`IpcError::AlreadyRunning`]. A stale + /// socket (connection refused) is removed and replaced. Any other kind + /// of filesystem object at the path is left untouched and rejected. + pub async fn acquire>(path: P) -> Result { + let path = path.as_ref().to_path_buf(); + let lock_path = socket_lock_path(&path); + let lock = open_lock_file(&lock_path)?; + lock_exclusive(&lock).map_err(|err| { + if err.kind() == std::io::ErrorKind::WouldBlock { + IpcError::AlreadyRunning(path.to_string_lossy().to_string()) + } else { + IpcError::Io(err) + } + })?; + + match std::fs::symlink_metadata(&path) { + Ok(metadata) => { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + + if !metadata.file_type().is_socket() { + return Err(IpcError::UnsafeSocketPath( + path.to_string_lossy().to_string(), + )); + } + // SAFETY: geteuid has no preconditions and does not touch + // memory; it only reads the process credentials. + let euid = unsafe { libc::geteuid() }; + if metadata.uid() != euid { + return Err(IpcError::UnsafeSocketPath( + path.to_string_lossy().to_string(), + )); + } + match UnixStream::connect(&path).await { + Ok(_) => { + return Err(IpcError::AlreadyRunning(path.to_string_lossy().to_string())); + } + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound + ) => + { + std::fs::remove_file(&path)?; + } + Err(err) => { + // EACCES, EMFILE, and similar conditions are not + // proof that the socket is stale, so never unlink it. + return Err(IpcError::Io(err)); + } + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(IpcError::Io(err)), + } + + let listener = UnixListener::bind(&path)?; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if let Err(err) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) { + let _ = std::fs::remove_file(&path); + return Err(IpcError::Io(err)); + } + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(err) => { + let _ = std::fs::remove_file(&path); + return Err(IpcError::Io(err)); + } + }; + + Ok(Self { + path, + listener, + dev: metadata.dev(), + ino: metadata.ino(), + _lock: lock, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Serves IPC connections until the listener fails. + /// + /// At most [`MAX_CONCURRENT_CONNECTIONS`] connections are handled at + /// once. Accepting is gated by the semaphore, so a flood backpressures in + /// the kernel backlog instead of allocating unbounded tasks. + pub async fn serve(self, handler: F) -> Result<(), IpcError> + where + F: Fn(IpcCommand) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + let handler = Arc::new(handler); + let permits = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS)); + loop { + let permit = match Arc::clone(&permits).acquire_owned().await { + Ok(permit) => permit, + Err(_) => return Ok(()), + }; + match self.listener.accept().await { + Ok((stream, _)) => { + let handler = Arc::clone(&handler); + tokio::spawn(async move { + let _permit = permit; + handle_connection(stream, handler).await; + }); + } + Err(err) => { + tracing::error!("IPC accept error: {:?}", err); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + } + + /// Runs [`IpcSocket::serve`] on a detached task. + pub fn spawn(self, handler: F) + where + F: Fn(IpcCommand) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + tokio::spawn(async move { + if let Err(err) = self.serve(handler).await { + tracing::error!("IPC server stopped: {err}"); + } + }); + } +} + +/// Identity of the bound socket inode, used to clean up safely on paths +/// where `Drop` cannot run (for example a process-wide shutdown handler). +#[derive(Debug, Clone, Copy)] +pub struct IpcSocketIdentity { + dev: u64, + ino: u64, +} + +impl IpcSocket { + pub fn identity(&self) -> IpcSocketIdentity { + IpcSocketIdentity { + dev: self.dev, + ino: self.ino, + } + } +} + +/// Removes `path` only when it is still the exact socket inode identified by +/// `identity`. Returns whether a file was removed. +pub fn remove_socket_if_owned(path: &Path, identity: IpcSocketIdentity) -> bool { + let owned = std::fs::symlink_metadata(path) + .is_ok_and(|metadata| existed_as_same_socket(&metadata, identity.dev, identity.ino)); + if owned { + let _ = std::fs::remove_file(path); + } + owned +} + +impl Drop for IpcSocket { + fn drop(&mut self) { + let _ = remove_socket_if_owned(&self.path, self.identity()); + } +} + +fn existed_as_same_socket(metadata: &std::fs::Metadata, dev: u64, ino: u64) -> bool { + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + metadata.file_type().is_socket() && metadata.dev() == dev && metadata.ino() == ino +} + +fn socket_lock_path(path: &Path) -> PathBuf { + let mut name = path + .file_name() + .map(|name| name.to_os_string()) + .unwrap_or_else(|| std::ffi::OsString::from("wallr.sock")); + name.push(".lock"); + path.with_file_name(name) +} + +fn open_lock_file(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path) +} + +fn lock_exclusive(file: &std::fs::File) -> std::io::Result<()> { + use std::os::unix::io::AsRawFd; + // SAFETY: the fd belongs to `file`, which outlives this call. flock only + // manipulates kernel lock state for that fd and does not access memory. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +async fn handle_connection(stream: UnixStream, handler: Arc) +where + F: Fn(IpcCommand) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, +{ + let (reader, mut writer) = tokio::io::split(stream); + let reader = BufReader::new(reader); + let mut limited = reader.take((MAX_IPC_BYTES + 1) as u64); + let mut buf = Vec::with_capacity(1024); + let response = + match tokio::time::timeout(REQUEST_TIMEOUT, limited.read_until(b'\n', &mut buf)).await { + Err(_) => IpcResponse::err("IPC request timed out"), + Ok(Ok(0)) => return, + Ok(Ok(_)) if buf.len() > MAX_IPC_BYTES => { + IpcResponse::err(format!("request exceeds {MAX_IPC_BYTES} bytes")) + } + Ok(Ok(_)) => match serde_json::from_slice::(&buf) { + Ok(cmd) => match validate_command(&cmd) { + Ok(()) => match tokio::time::timeout(HANDLER_TIMEOUT, handler(cmd)).await { + Ok(response) => response, + Err(_) => IpcResponse::err("IPC command timed out"), + }, + Err(reason) => IpcResponse::err(reason), + }, + Err(e) => IpcResponse::err(format!("invalid IPC command: {e}")), + }, + Ok(Err(e)) => IpcResponse::err(format!("IPC read error: {e}")), + }; + write_response(&mut writer, &response).await; +} + +async fn write_response(writer: &mut tokio::io::WriteHalf, response: &IpcResponse) { + let Ok(res_data) = serde_json::to_vec(response) else { + return; + }; + let write = async { + writer.write_all(&res_data).await?; + writer.write_all(b"\n").await?; + writer.flush().await + }; + let _ = tokio::time::timeout(WRITE_TIMEOUT, write).await; +} + +async fn read_response(reader: &mut BufReader) -> Result, IpcError> { + let mut limited = reader.take((MAX_IPC_RESPONSE_BYTES + 1) as u64); + let mut response_line = Vec::new(); + let read = limited.read_until(b'\n', &mut response_line); + match tokio::time::timeout(RESPONSE_TIMEOUT, read).await { + Ok(Ok(0)) => Err(IpcError::Protocol(serde_json::Error::io( + std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "daemon closed the connection without a response", + ), + ))), + Ok(Ok(_)) if response_line.len() > MAX_IPC_RESPONSE_BYTES => { + Err(IpcError::ResponseTooLarge) + } + Ok(Ok(_)) => Ok(response_line), + Ok(Err(err)) => Err(IpcError::Io(err)), + Err(_) => Err(IpcError::Timeout("waiting for daemon response".to_string())), + } +} + pub async fn send_ipc_command>( socket_path: P, command: IpcCommand, ) -> Result { - let mut stream = UnixStream::connect(socket_path) - .await - .map_err(|e| IpcError::DaemonNotRunning(e.to_string()))?; + let socket_path = socket_path.as_ref(); + let mut stream = + match tokio::time::timeout(CONNECT_TIMEOUT, UnixStream::connect(socket_path)).await { + Ok(Ok(stream)) => stream, + Ok(Err(err)) => return Err(IpcError::DaemonNotRunning(err.to_string())), + Err(_) => { + return Err(IpcError::Timeout(format!( + "connecting to {}", + socket_path.display() + ))); + } + }; let req_data = serde_json::to_vec(&command)?; - stream.write_all(&req_data).await?; - stream.write_all(b"\n").await?; - stream.flush().await?; + let write = async { + stream.write_all(&req_data).await?; + stream.write_all(b"\n").await?; + stream.flush().await + }; + match tokio::time::timeout(WRITE_TIMEOUT, write).await { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(IpcError::Io(err)), + Err(_) => return Err(IpcError::Timeout("sending IPC request".to_string())), + } let mut reader = BufReader::new(stream); - let mut response_line = String::new(); - reader.read_line(&mut response_line).await?; - - let response: IpcResponse = serde_json::from_str(&response_line)?; + let response_line = read_response(&mut reader).await?; + let response: IpcResponse = serde_json::from_slice(&response_line)?; Ok(response) } -async fn write_response(writer: &mut tokio::io::WriteHalf, response: &IpcResponse) { - if let Ok(res_data) = serde_json::to_vec(response) { - let _ = writer.write_all(&res_data).await; - let _ = writer.write_all(b"\n").await; - let _ = writer.flush().await; - } -} - +/// Bundled server helper kept for callers that do not need to reserve the +/// socket before setting up the rest of the daemon. pub async fn start_ipc_server(socket_path: P, handler: F) -> Result<(), IpcError> where P: AsRef, F: Fn(IpcCommand) -> Fut + Send + Sync + 'static, Fut: std::future::Future + Send + 'static, { - let path = socket_path.as_ref(); - if path.exists() { - let _ = std::fs::remove_file(path); + IpcSocket::acquire(socket_path).await?.spawn(handler); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[tokio::test] + async fn socket_is_private_and_removed_on_drop() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("wallr.sock"); + let socket = IpcSocket::acquire(&path).await.unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + drop(socket); + assert!(!path.exists()); } - let listener = UnixListener::bind(path)?; - let handler = std::sync::Arc::new(handler); + #[tokio::test] + async fn live_socket_blocks_a_second_daemon() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("wallr.sock"); + let _first = IpcSocket::acquire(&path).await.unwrap(); + assert!(matches!( + IpcSocket::acquire(&path).await, + Err(IpcError::AlreadyRunning(_)) + )); + } - tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, _)) => { - let handler_clone = handler.clone(); - tokio::spawn(async move { - let (reader, mut writer) = tokio::io::split(stream); - let reader = BufReader::new(reader); - // Bound the request: `take` caps memory even when a - // client never sends a newline. - let mut limited = reader.take((MAX_IPC_BYTES + 1) as u64); - let mut buf = Vec::with_capacity(1024); - let response = match limited.read_until(b'\n', &mut buf).await { - Ok(0) => return, - Ok(_) if buf.len() > MAX_IPC_BYTES => { - IpcResponse::err(format!("request exceeds {MAX_IPC_BYTES} bytes")) - } - Ok(_) => match serde_json::from_slice::(&buf) { - Ok(cmd) => match validate_command(&cmd) { - Ok(()) => handler_clone(cmd).await, - Err(reason) => IpcResponse::err(reason), - }, - Err(e) => IpcResponse::err(format!("invalid IPC command: {e}")), - }, - Err(e) => IpcResponse::err(format!("IPC read error: {e}")), - }; - write_response(&mut writer, &response).await; - }); - } - Err(e) => { - tracing::error!("IPC accept error: {:?}", e); - } + #[tokio::test] + async fn stale_socket_is_replaced_but_regular_files_are_preserved() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("wallr.sock"); + drop(std::os::unix::net::UnixListener::bind(&path).unwrap()); + assert!(path.exists()); + let socket = IpcSocket::acquire(&path).await.unwrap(); + drop(socket); + assert!(!path.exists()); + + let regular = temp.path().join("not-a-socket"); + std::fs::write(®ular, b"user data").unwrap(); + assert!(matches!( + IpcSocket::acquire(®ular).await, + Err(IpcError::UnsafeSocketPath(_)) + )); + assert_eq!(std::fs::read(®ular).unwrap(), b"user data"); + } + + #[tokio::test] + async fn server_round_trips_commands_and_rejects_empty_preview_paths() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("wallr.sock"); + let socket = IpcSocket::acquire(&path).await.unwrap(); + let server_path = path.clone(); + socket.spawn(|cmd| async move { + match cmd { + IpcCommand::Status => IpcResponse { + success: true, + message: Some("ok".into()), + }, + other => IpcResponse::err(format!("unexpected {other:?}")), } - } - }); + }); + let response = send_ipc_command(&server_path, IpcCommand::Status) + .await + .unwrap(); + assert!(response.success); + assert_eq!(response.message.as_deref(), Some("ok")); - Ok(()) + let response = send_ipc_command( + &server_path, + IpcCommand::Preview { + path: String::new(), + effect: None, + duration_ms: None, + no_theme: true, + theme_override: None, + monitor: None, + scaling_mode: None, + }, + ) + .await + .unwrap(); + assert!(!response.success); + } } diff --git a/wallr-core/src/renderer/mod.rs b/wallr-core/src/renderer/mod.rs index 33065cf..c58bf39 100644 --- a/wallr-core/src/renderer/mod.rs +++ b/wallr-core/src/renderer/mod.rs @@ -180,9 +180,9 @@ impl Renderer { // fallback most systems never need. #[cfg(target_os = "linux")] { - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::Backends::VULKAN, - ..Default::default() + ..wgpu::InstanceDescriptor::new_without_display_handle() }); if let Ok(adapter) = crate::video::select_adapter(&instance, preference).await { break 'select (instance, adapter); @@ -195,9 +195,9 @@ impl Renderer { let backends = wgpu::Backends::VULKAN | wgpu::Backends::GL; #[cfg(not(target_os = "linux"))] let backends = wgpu::Backends::all(); - let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, - ..Default::default() + ..wgpu::InstanceDescriptor::new_without_display_handle() }); let adapter = crate::video::select_adapter(&instance, preference) .await @@ -212,19 +212,18 @@ impl Renderer { }; let (device, queue) = adapter - .request_device( - &wgpu::DeviceDescriptor { - label: None, - required_features: wgpu::Features::empty(), - required_limits, - // Wallr is a persistent background process, not a game: - // prefer smaller allocations and lower residency over - // speculative throughput. Frame pacing remains governed - // by FIFO presentation and the compositor. - memory_hints: wgpu::MemoryHints::MemoryUsage, - }, - None, - ) + .request_device(&wgpu::DeviceDescriptor { + label: None, + required_features: wgpu::Features::empty(), + required_limits, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + // Wallr is a persistent background process, not a game: + // prefer smaller allocations and lower residency over + // speculative throughput. Frame pacing remains governed + // by FIFO presentation and the compositor. + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::default(), + }) .await?; let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -318,11 +317,11 @@ impl Renderer { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Render Pipeline Layout"), bind_group_layouts: &[ - &bind_group_layout_tex, - &bind_group_layout_tex, - &bind_group_layout_uni, + Some(&bind_group_layout_tex), + Some(&bind_group_layout_tex), + Some(&bind_group_layout_uni), ], - push_constant_ranges: &[], + immediate_size: 0, }); let nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -333,8 +332,8 @@ impl Renderer { }); let nv12_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("NV12 Conversion Pipeline Layout"), - bind_group_layouts: &[&nv12_bind_group_layout], - push_constant_ranges: &[], + bind_group_layouts: &[Some(&nv12_bind_group_layout)], + immediate_size: 0, }); let nv12_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("NV12 Conversion Pipeline"), @@ -358,7 +357,7 @@ impl Renderer { primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), - multiview: None, + multiview_mask: None, cache: None, }); @@ -369,7 +368,7 @@ impl Renderer { address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, - mipmap_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, ..Default::default() }); let video_plane_sampler = device.create_sampler(&wgpu::SamplerDescriptor { @@ -379,7 +378,7 @@ impl Renderer { address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, - mipmap_filter: wgpu::FilterMode::Nearest, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() }); let video_output_sampler = device.create_sampler(&wgpu::SamplerDescriptor { @@ -389,7 +388,7 @@ impl Renderer { address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, - mipmap_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, ..Default::default() }); @@ -497,7 +496,7 @@ impl Renderer { mask: !0, alpha_to_coverage_enabled: false, }, - multiview: None, + multiview_mask: None, cache: None, }); @@ -769,6 +768,7 @@ impl Renderer { color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &texture.output_view, resolve_target: None, + depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, @@ -777,6 +777,7 @@ impl Renderer { depth_stencil_attachment: None, occlusion_query_set: None, timestamp_writes: None, + multiview_mask: None, }); pass.set_pipeline(&self.nv12_pipeline); pass.set_bind_group(0, &texture.conversion_bind_group, &[]); @@ -908,13 +909,15 @@ impl Renderer { // parks this call, which is safe because transition loops always run // on detached tasks that never block the daemon's IPC loop. let output = match surface.get_current_texture() { - Ok(texture) => texture, - Err(wgpu::SurfaceError::Timeout) => return Ok(FrameStatus::TimedOut), - Err(wgpu::SurfaceError::Outdated) => return Ok(FrameStatus::Outdated), - Err(wgpu::SurfaceError::Lost) => return Ok(FrameStatus::Lost), - Err(err) => { + wgpu::CurrentSurfaceTexture::Success(texture) => texture, + wgpu::CurrentSurfaceTexture::Suboptimal(texture) => texture, + wgpu::CurrentSurfaceTexture::Timeout => return Ok(FrameStatus::TimedOut), + wgpu::CurrentSurfaceTexture::Outdated => return Ok(FrameStatus::Outdated), + wgpu::CurrentSurfaceTexture::Lost => return Ok(FrameStatus::Lost), + wgpu::CurrentSurfaceTexture::Occluded => return Ok(FrameStatus::TimedOut), + wgpu::CurrentSurfaceTexture::Validation => { return Err(anyhow::anyhow!( - "failed to acquire swapchain texture: {err:?}" + "failed to acquire swapchain texture: validation error" )); } }; @@ -934,6 +937,7 @@ impl Renderer { color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, + depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, @@ -942,6 +946,7 @@ impl Renderer { depth_stencil_attachment: None, occlusion_query_set: None, timestamp_writes: None, + multiview_mask: None, }); render_pass.set_pipeline(&pipeline); @@ -953,7 +958,7 @@ impl Renderer { } self.queue.submit(std::iter::once(encoder.finish())); - output.present(); + self.queue.present(output); Ok(FrameStatus::Presented) } @@ -1140,6 +1145,7 @@ mod tests { color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, + depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, @@ -1148,6 +1154,7 @@ mod tests { depth_stencil_attachment: None, occlusion_query_set: None, timestamp_writes: None, + multiview_mask: None, }); pass.set_pipeline(&pipeline); pass.set_bind_group(0, &bind_old, &[]); @@ -1185,9 +1192,12 @@ mod tests { renderer.queue.submit([encoder.finish()]); let slice = readback.slice(..); slice.map_async(wgpu::MapMode::Read, |_| {}); - renderer.device.poll(wgpu::Maintain::Wait); + renderer + .device + .poll(wgpu::PollType::wait_indefinitely()) + .ok(); let pixels = { - let mapped = slice.get_mapped_range(); + let mapped = slice.get_mapped_range().unwrap(); mapped.to_vec() }; readback.unmap(); diff --git a/wallr-core/src/theme/mod.rs b/wallr-core/src/theme/mod.rs index c6d2738..becba9a 100644 --- a/wallr-core/src/theme/mod.rs +++ b/wallr-core/src/theme/mod.rs @@ -2,8 +2,18 @@ use crate::config::{MatugenConfig, ThemeProvider}; use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::time::UNIX_EPOCH; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant, UNIX_EPOCH}; + +/// Upper bound for a theme provider run. A provider that hangs must not pin +/// a daemon blocking thread forever. +const THEME_TIMEOUT: Duration = Duration::from_secs(120); +/// Upper bound for a user hook. Hooks commonly launch desktop utilities, so +/// allow more than a provider but still bound the run. +const HOOK_TIMEOUT: Duration = Duration::from_secs(60); +/// Reload helpers should return quickly; a stuck `pkill`/shell is ignored +/// after this deadline. +const RELOAD_TIMEOUT: Duration = Duration::from_secs(15); #[derive(Debug, thiserror::Error)] pub enum ThemeError { @@ -11,6 +21,8 @@ pub enum ThemeError { SpawnError(#[from] std::io::Error), #[error("theme provider exited with code {0}: {1}")] NonZeroExit(i32, String), + #[error("command timed out after {0:?}: {1}")] + Timeout(Duration, String), } pub fn dispatch_theme( @@ -114,15 +126,19 @@ fn resolve_theme_image(image_path: &Path) -> Option { fn theme_cache_key(path: &Path) -> Result { let meta = fs::metadata(path).map_err(|e| e.to_string())?; - let mtime = meta - .modified() - .map_err(|e| e.to_string())? + let modified = meta.modified().map_err(|e| e.to_string())?; + let nanos = modified .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + .map_err(|e| e.to_string())? + .as_nanos(); let mut hasher = Sha256::new(); - hasher.update(path.to_string_lossy().as_bytes()); - hasher.update(mtime.to_string().as_bytes()); + // Hash the OS string bytes rather than lossy UTF-8: distinct non-UTF-8 + // paths must not share a cache entry. + hasher.update(path.as_os_str().as_encoded_bytes()); + hasher.update(b"\0"); + hasher.update(nanos.to_le_bytes()); + hasher.update(b"\0"); + hasher.update(meta.len().to_le_bytes()); Ok(hex::encode(hasher.finalize())) } @@ -138,11 +154,17 @@ fn extract_gif_first_frame(path: &Path, dest: &Path) -> Result<(), String> { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - // Write to a temp file then atomically rename to avoid partial writes - let tmp = dest.with_extension("tmp.png"); - img.save_with_format(&tmp, image::ImageFormat::Png) + // Save to a unique temp file and atomically rename into the cache. A + // fixed `.tmp.png` name let two concurrent extractions corrupt each + // other's output. + let parent = dest + .parent() + .ok_or_else(|| "cache path has no parent".to_string())?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + img.save_with_format(tmp.path(), image::ImageFormat::Png) .map_err(|e| e.to_string())?; - fs::rename(&tmp, dest).map_err(|e| e.to_string())?; + tmp.persist(dest).map_err(|e| e.error.to_string())?; Ok(()) } @@ -205,16 +227,17 @@ fn extract_video_first_frame(path: &Path, dest: &Path) -> Result<(), String> { return Err("no frame decoded from video".to_string()); } - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } + let parent = dest + .parent() + .ok_or_else(|| "cache path has no parent".to_string())?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; let img = image::RgbImage::from_raw(out_w, out_h, packed) .ok_or_else(|| "failed to create image buffer".to_string())?; - let tmp = dest.with_extension("tmp.png"); - img.save_with_format(&tmp, image::ImageFormat::Png) + let tmp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + img.save_with_format(tmp.path(), image::ImageFormat::Png) .map_err(|e| e.to_string())?; - fs::rename(&tmp, dest).map_err(|e| e.to_string())?; + tmp.persist(dest).map_err(|e| e.error.to_string())?; Ok(()) } @@ -284,13 +307,17 @@ fn run_matugen(image_path: &Path, config: &MatugenConfig) -> Result<(), ThemeErr let mut child = cmd.spawn().map_err(ThemeError::SpawnError)?; if config.wait { - let status = child.wait().map_err(ThemeError::SpawnError)?; + let status = wait_with_timeout(&mut child, THEME_TIMEOUT, "matugen")?; if !status.success() { return Err(ThemeError::NonZeroExit( status.code().unwrap_or(-1), "matugen failed".to_string(), )); } + } else { + // `Child` does not reap on drop. A daemon is long-lived, so spawn a + // small reaper thread instead of accumulating zombie processes. + std::thread::spawn(move || reap_with_timeout(&mut child, THEME_TIMEOUT)); } Ok(()) @@ -303,7 +330,8 @@ fn run_wallust(image_path: &Path) -> Result<(), ThemeError> { .stdout(Stdio::null()) .stderr(Stdio::null()); - let status = cmd.status().map_err(ThemeError::SpawnError)?; + let mut child = cmd.spawn().map_err(ThemeError::SpawnError)?; + let status = wait_with_timeout(&mut child, THEME_TIMEOUT, "wallust")?; if !status.success() { return Err(ThemeError::NonZeroExit( status.code().unwrap_or(-1), @@ -321,7 +349,8 @@ fn run_pywal(image_path: &Path) -> Result<(), ThemeError> { .stdout(Stdio::null()) .stderr(Stdio::null()); - let status = cmd.status().map_err(ThemeError::SpawnError)?; + let mut child = cmd.spawn().map_err(ThemeError::SpawnError)?; + let status = wait_with_timeout(&mut child, THEME_TIMEOUT, "pywal")?; if !status.success() { return Err(ThemeError::NonZeroExit( status.code().unwrap_or(-1), @@ -367,11 +396,10 @@ pub fn detect_matugen_loop_risk() -> Option { /// Runs hook commands sequentially. User hooks output is preserved. pub fn run_hooks(hooks: &[String]) -> Result<(), ThemeError> { for hook in hooks { - let status = Command::new("sh") - .arg("-c") - .arg(hook) - .status() - .map_err(ThemeError::SpawnError)?; + let mut command = Command::new("sh"); + command.arg("-c").arg(hook); + let mut child = command.spawn().map_err(ThemeError::SpawnError)?; + let status = wait_with_timeout(&mut child, HOOK_TIMEOUT, hook)?; if !status.success() { return Err(ThemeError::NonZeroExit( @@ -386,25 +414,47 @@ pub fn run_hooks(hooks: &[String]) -> Result<(), ThemeError> { /// Runs reload commands, via shell or pkill quietly (swallows failure if app isn't running). pub fn run_reload_list(commands: &[String]) -> Result<(), ThemeError> { for cmd in commands { - if cmd.contains(' ') { - let _ = Command::new("sh") - .arg("-c") - .arg(cmd) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); + let mut command = if cmd.contains(' ') { + let mut command = Command::new("sh"); + command.arg("-c").arg(cmd); + command } else { - let _ = Command::new("pkill") - .arg("-SIGUSR2") - .arg(cmd) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); + let mut command = Command::new("pkill"); + command.arg("-SIGUSR2").arg(cmd); + command + }; + command.stdout(Stdio::null()).stderr(Stdio::null()); + if let Ok(mut child) = command.spawn() { + let _ = wait_with_timeout(&mut child, RELOAD_TIMEOUT, cmd); } } Ok(()) } +fn wait_with_timeout( + child: &mut Child, + timeout: Duration, + label: &str, +) -> Result { + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) if started.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(ThemeError::Timeout(timeout, label.to_string())); + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(err) => return Err(ThemeError::SpawnError(err)), + } + } +} + +fn reap_with_timeout(child: &mut Child, timeout: Duration) { + let _ = wait_with_timeout(child, timeout, "detached theme provider"); +} + #[cfg(test)] mod tests { use super::*; @@ -439,4 +489,32 @@ mod tests { // Should not detect a loop when wallr is not invoked by matugen assert!(detect_matugen_loop_risk().is_none()); } + + #[test] + fn cache_key_uses_subsecond_mtime_and_size() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wall.gif"); + std::fs::write(&path, b"one").unwrap(); + let first = theme_cache_key(&path).unwrap(); + let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_millis(1_000)) + .unwrap(); + let second = theme_cache_key(&path).unwrap(); + assert_ne!(first, second, "sub-second mtime changes must invalidate"); + + file.set_modified(UNIX_EPOCH + Duration::from_millis(1_500)) + .unwrap(); + std::fs::write(&path, b"two").unwrap(); + let third = theme_cache_key(&path).unwrap(); + assert_ne!(second, third, "size changes must invalidate"); + } + + #[test] + fn hung_commands_are_killed_and_reported() { + let mut child = Command::new("sh").arg("-c").arg("sleep 5").spawn().unwrap(); + let started = Instant::now(); + let result = wait_with_timeout(&mut child, Duration::from_millis(50), "test"); + assert!(matches!(result, Err(ThemeError::Timeout(..)))); + assert!(started.elapsed() < Duration::from_secs(2)); + } } diff --git a/wallr-core/src/video/decoder.rs b/wallr-core/src/video/decoder.rs index 2189546..4100baa 100644 --- a/wallr-core/src/video/decoder.rs +++ b/wallr-core/src/video/decoder.rs @@ -342,8 +342,13 @@ impl VideoDecoder { .unwrap_or_else(|| "unknown".to_string()); let frame_rate = stream.avg_frame_rate(); - let fps = if frame_rate.numerator() > 0 { - frame_rate.numerator() as f64 / frame_rate.denominator() as f64 + let fps = if frame_rate.numerator() > 0 && frame_rate.denominator() > 0 { + let fps = frame_rate.numerator() as f64 / frame_rate.denominator() as f64; + if fps.is_finite() && fps > 0.0 { + fps + } else { + 30.0 + } } else { 30.0 }; @@ -351,22 +356,20 @@ impl VideoDecoder { let duration = { let duration_ts = stream.duration(); let time_base = stream.time_base(); - if duration_ts > 0 { - Duration::from_secs_f64( - duration_ts as f64 * time_base.numerator() as f64 - / time_base.denominator() as f64, - ) + let seconds = if duration_ts > 0 && time_base.denominator() > 0 { + duration_ts as f64 * time_base.numerator() as f64 / time_base.denominator() as f64 } else { - let container_duration = ictx.duration() as f64 / ffmpeg::ffi::AV_TIME_BASE as f64; - Duration::from_secs_f64(container_duration) - } + ictx.duration() as f64 / ffmpeg::ffi::AV_TIME_BASE as f64 + }; + duration_from_seconds(seconds)? }; - let total_frames = if fps > 0.0 { - (duration.as_secs_f64() * fps) as u64 - } else { - 0 - }; + if width == 0 || height == 0 { + return Err(VideoError::UnsupportedFormat( + "video has empty dimensions".to_string(), + )); + } + let total_frames = (duration.as_secs_f64() * fps).min(u64::MAX as f64) as u64; Ok(VideoMetadata { width, @@ -494,7 +497,7 @@ impl VideoDecoder { fn build_decoder( stream: &ffmpeg::format::stream::Stream, hw_accel: HwAccel, - ) -> (ffmpeg::codec::decoder::Video, HwAccel, DecoderState) { + ) -> VideoResult<(ffmpeg::codec::decoder::Video, HwAccel, DecoderState)> { let mut hardware_attempted = false; match hw_accel { HwAccel::Auto => { @@ -508,7 +511,7 @@ impl VideoDecoder { tried += 1; hardware_attempted = true; if let Some(result) = Self::try_hw_decoder(stream, backend) { - return (result.0, result.1, DecoderState::HardwareNegotiating); + return Ok((result.0, result.1, DecoderState::HardwareNegotiating)); } } if tried == 0 { @@ -525,7 +528,7 @@ impl VideoDecoder { // Try the requested hardware backend first hardware_attempted = true; if let Some(result) = Self::try_hw_decoder(stream, specific) { - return (result.0, result.1, DecoderState::HardwareNegotiating); + return Ok((result.0, result.1, DecoderState::HardwareNegotiating)); } // Fall back to software tracing::info!( @@ -535,12 +538,13 @@ impl VideoDecoder { } } - // Software fallback. + // Software fallback. An unavailable software decoder (unsupported + // codec) is a normal error, not a reason to panic the decode thread. let decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters()) .and_then(|ctx| ctx.decoder().video()) - .expect("software decoder must be available"); + .map_err(|e| VideoError::UnsupportedCodec(e.to_string()))?; let state = software_decoder_state(hardware_attempted); - (decoder, HwAccel::Software, state) + Ok((decoder, HwAccel::Software, state)) } #[allow(clippy::too_many_arguments)] @@ -571,7 +575,7 @@ impl VideoDecoder { let video_stream_index = stream.index(); let time_base = stream.time_base(); - let (mut decoder, used_hw, initial_state) = Self::build_decoder(&stream, hw_accel); + let (mut decoder, used_hw, initial_state) = Self::build_decoder(&stream, hw_accel)?; tracing::info!("Decoder in use: {}", used_hw.name()); decoder_state.store(initial_state.code(), Ordering::Release); fallback_occurred.store( @@ -974,6 +978,18 @@ fn ensure_ffmpeg_init() -> VideoResult<()> { .map(|_| ()) } +/// Converts untrusted container timing to a `Duration` without panicking on +/// NaN, negative, infinite, or beyond-`Duration::MAX` values. +fn duration_from_seconds(seconds: f64) -> VideoResult { + if !seconds.is_finite() || seconds < 0.0 { + return Err(VideoError::InvalidDuration(format!( + "container reported {seconds} seconds" + ))); + } + Duration::try_from_secs_f64(seconds) + .map_err(|_| VideoError::InvalidDuration(format!("{seconds} seconds is out of range"))) +} + const fn software_decoder_state(hardware_attempted: bool) -> DecoderState { if hardware_attempted { DecoderState::SoftwareFallback @@ -1059,12 +1075,38 @@ fn vaapi_device_candidates() -> Vec { candidates } -fn copy_packed_rows(source: &[u8], stride: usize, row_bytes: usize, height: usize) -> Vec { - let mut packed = Vec::with_capacity(row_bytes * height); +fn copy_packed_rows( + source: &[u8], + stride: usize, + row_bytes: usize, + height: usize, +) -> VideoResult> { + let invalid = |message: String| VideoError::FormatConversionFailed(anyhow::anyhow!(message)); + if height == 0 { + return Err(invalid("frame has zero height".to_string())); + } + if stride < row_bytes { + return Err(invalid(format!( + "frame stride {stride} is smaller than row width {row_bytes}" + ))); + } + let needed = stride + .checked_mul(height) + .ok_or_else(|| invalid("frame stride overflow".to_string()))?; + if source.len() < needed { + return Err(invalid(format!( + "frame buffer {} is smaller than required {needed}", + source.len() + ))); + } + let capacity = row_bytes + .checked_mul(height) + .ok_or_else(|| invalid("frame size overflow".to_string()))?; + let mut packed = Vec::with_capacity(capacity); for row in source.chunks(stride).take(height) { packed.extend_from_slice(&row[..row_bytes]); } - packed + Ok(packed) } /// Converts a decoded frame into `VideoFrameData`, keeping NV12 on the GPU @@ -1091,7 +1133,7 @@ fn convert_frame( value => value, }; let color = select_yuv_color(color_space, color_range, width, height); - let (y_plane, uv_plane) = copy_nv12_planes(src_frame); + let (y_plane, uv_plane) = copy_nv12_planes(src_frame)?; Ok(VideoFrameData::Nv12 { y_plane, uv_plane, @@ -1123,13 +1165,15 @@ fn convert_frame( Ok(VideoFrameData::Rgba(copy_packed_rows( rgb_frame.data(0), rgb_frame.stride(0), - rgb_frame.width() as usize * 4, + (rgb_frame.width() as usize).checked_mul(4).ok_or_else(|| { + VideoError::FormatConversionFailed(anyhow::anyhow!("frame width overflow")) + })?, rgb_frame.height() as usize, - ))) + )?)) } } -fn copy_nv12_planes(frame: &ffmpeg::frame::Video) -> (Vec, Vec) { +fn copy_nv12_planes(frame: &ffmpeg::frame::Video) -> VideoResult<(Vec, Vec)> { copy_nv12_data( frame.data(0), frame.stride(0), @@ -1147,13 +1191,21 @@ fn copy_nv12_data( uv_stride: usize, width: usize, height: usize, -) -> (Vec, Vec) { +) -> VideoResult<(Vec, Vec)> { + if width == 0 || height == 0 { + return Err(VideoError::FormatConversionFailed(anyhow::anyhow!( + "NV12 frame is empty" + ))); + } let chroma_width = width.div_ceil(2); let chroma_height = height.div_ceil(2); - ( - copy_packed_rows(y_source, y_stride, width, height), - copy_packed_rows(uv_source, uv_stride, chroma_width * 2, chroma_height), - ) + let uv_row = chroma_width.checked_mul(2).ok_or_else(|| { + VideoError::FormatConversionFailed(anyhow::anyhow!("NV12 chroma row overflow")) + })?; + Ok(( + copy_packed_rows(y_source, y_stride, width, height)?, + copy_packed_rows(uv_source, uv_stride, uv_row, chroma_height)?, + )) } fn select_yuv_color( @@ -1322,7 +1374,7 @@ mod tests { fn packs_strided_rows() { let y = [1, 2, 3, 99, 99, 4, 5, 6, 99, 99, 7, 8, 9, 99, 99]; let uv = [10, 11, 12, 13, 99, 99, 14, 15, 16, 17, 99, 99]; - let (packed_y, packed_uv) = copy_nv12_data(&y, 5, &uv, 6, 3, 3); + let (packed_y, packed_uv) = copy_nv12_data(&y, 5, &uv, 6, 3, 3).unwrap(); assert_eq!(packed_y, [1, 2, 3, 4, 5, 6, 7, 8, 9]); assert_eq!(packed_uv, [10, 11, 12, 13, 14, 15, 16, 17]); } @@ -1394,4 +1446,22 @@ mod tests { ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NONE ); } + #[test] + fn rejects_truncated_or_inverted_frame_rows() { + assert!(copy_packed_rows(&[0; 10], 5, 6, 2).is_err()); + assert!(copy_packed_rows(&[0; 10], 5, 5, 3).is_err()); + assert!(copy_packed_rows(&[0; 10], 4, 5, 2).is_err()); + assert!(copy_packed_rows(&[0; 15], 5, 3, 3).is_ok()); + } + #[test] + fn untrusted_duration_values_never_panic() { + for value in [f64::NAN, f64::INFINITY, -1.0, f64::MAX] { + assert!(duration_from_seconds(value).is_err(), "{value}"); + } + assert_eq!( + duration_from_seconds(1.5).unwrap(), + Duration::from_millis(1500) + ); + assert_eq!(duration_from_seconds(0.0).unwrap(), Duration::ZERO); + } } diff --git a/wallr-core/src/video/error.rs b/wallr-core/src/video/error.rs index 258465d..1c3dec7 100644 --- a/wallr-core/src/video/error.rs +++ b/wallr-core/src/video/error.rs @@ -47,6 +47,9 @@ pub enum VideoError { #[error("invalid presentation timestamp")] InvalidPts, + #[error("invalid video duration: {0}")] + InvalidDuration(String), + #[error("video duration could not be determined")] UnknownDuration, diff --git a/wallr-core/src/video/gpu.rs b/wallr-core/src/video/gpu.rs index 62654ec..523a232 100644 --- a/wallr-core/src/video/gpu.rs +++ b/wallr-core/src/video/gpu.rs @@ -34,7 +34,7 @@ impl fmt::Display for AdapterInfo { pub async fn detect_adapters(instance: &wgpu::Instance) -> Vec { let mut adapters = Vec::new(); - for adapter in instance.enumerate_adapters(wgpu::Backends::all()) { + for adapter in instance.enumerate_adapters(wgpu::Backends::all()).await { let info = adapter.get_info(); adapters.push(AdapterInfo { name: info.name.clone(), @@ -57,6 +57,7 @@ pub async fn select_adapter( ) -> VideoResult { let adapters: Vec<_> = instance .enumerate_adapters(wgpu::Backends::all()) + .await .into_iter() .map(|adapter| { let info = adapter.get_info(); diff --git a/wallr-core/src/video/playback.rs b/wallr-core/src/video/playback.rs index cfd6c3a..9fdfb58 100644 --- a/wallr-core/src/video/playback.rs +++ b/wallr-core/src/video/playback.rs @@ -77,6 +77,11 @@ impl VideoPlayback { if let Some(frame) = decoder.next_frame() { break Some(frame); } + if decoder.decoder_state() == crate::video::DecoderState::Failed { + return Err(crate::video::VideoError::DecodeFailed(anyhow::anyhow!( + "decoder failed before producing a frame" + ))); + } if Instant::now() >= deadline { tracing::warn!("Timed out waiting for first video frame"); break None; diff --git a/wallr-core/src/video/scheduler.rs b/wallr-core/src/video/scheduler.rs index a012070..f307ac7 100644 --- a/wallr-core/src/video/scheduler.rs +++ b/wallr-core/src/video/scheduler.rs @@ -28,6 +28,10 @@ impl ScheduledFrame { pub struct FrameScheduler { start_time: Instant, + /// Position at `start_time`. Storing an offset instead of subtracting + /// from `Instant::now()` avoids underflow panics when seeking beyond the + /// system uptime (a valid multi-hour video on a freshly booted host). + position_offset: Duration, duration: Duration, paused: bool, pause_duration: Duration, @@ -40,6 +44,7 @@ impl FrameScheduler { pub fn new(duration: Duration) -> Self { Self { start_time: Instant::now(), + position_offset: Duration::ZERO, duration, paused: false, pause_duration: Duration::ZERO, @@ -55,7 +60,10 @@ impl FrameScheduler { } else { Instant::now() }; - let elapsed = base.duration_since(self.start_time) - self.pause_duration; + let elapsed = base + .saturating_duration_since(self.start_time) + .saturating_add(self.position_offset) + .saturating_sub(self.pause_duration); let total_ms = self.duration.as_millis().max(1); Duration::from_millis((elapsed.as_millis() % total_ms) as u64) } @@ -89,8 +97,11 @@ impl FrameScheduler { anyhow::anyhow!("Position exceeds video duration"), )); } - self.start_time = Instant::now() - position; + let now = Instant::now(); + self.start_time = now; + self.position_offset = position; self.pause_duration = Duration::ZERO; + self.pause_start = self.paused.then_some(now); self.last_frame_pts = None; tracing::info!("Seeked to {:?}", position); Ok(()) @@ -98,6 +109,7 @@ impl FrameScheduler { pub fn reset(&mut self) { self.start_time = Instant::now(); + self.position_offset = Duration::ZERO; self.pause_duration = Duration::ZERO; self.pause_start = None; self.paused = false; @@ -222,7 +234,16 @@ mod tests { assert!(scheduler.should_upload(Duration::from_millis(900))); assert!(!scheduler.should_display(Duration::ZERO)); - scheduler.start_time = Instant::now() - Duration::from_millis(10); + scheduler.position_offset = Duration::from_millis(10); assert!(scheduler.should_display(Duration::ZERO)); } + #[test] + fn seek_beyond_system_uptime_does_not_panic() { + let mut scheduler = FrameScheduler::new(Duration::from_secs(365 * 24 * 3600)); + let target = Duration::from_secs(300 * 24 * 3600); + scheduler.seek(target).unwrap(); + let position = scheduler.current_position(); + assert!(position >= target - Duration::from_secs(1)); + assert!(position <= target + Duration::from_secs(1)); + } } diff --git a/wallr-core/src/wallpaper/mod.rs b/wallr-core/src/wallpaper/mod.rs index 8faee08..1419b9a 100644 --- a/wallr-core/src/wallpaper/mod.rs +++ b/wallr-core/src/wallpaper/mod.rs @@ -116,6 +116,18 @@ impl WallpaperEngine { &mut self, path: &Path, options: &SetOptions, + ) -> Result<(), WallpaperError> { + self.set_wallpaper_blocking(path, options) + } + + /// Synchronous implementation for callers that must run it on a blocking + /// worker. Theme providers and hooks spawn and wait for external + /// processes; running them directly on a Tokio worker stalls the IPC + /// loop and every other task on that runtime. + pub fn set_wallpaper_blocking( + &mut self, + path: &Path, + options: &SetOptions, ) -> Result<(), WallpaperError> { info!("Setting wallpaper to: {:?}", path); @@ -156,6 +168,12 @@ impl WallpaperEngine { } pub fn reload(&self) -> Result<(), WallpaperError> { + self.reload_blocking() + } + + /// Synchronous implementation for blocking workers; see + /// [`WallpaperEngine::set_wallpaper_blocking`]. + pub fn reload_blocking(&self) -> Result<(), WallpaperError> { info!("Running reload commands..."); theme::run_reload_list(&self.config.reload)?; Ok(()) diff --git a/wallr/Cargo.toml b/wallr/Cargo.toml index 1bd1712..fcc9edc 100644 --- a/wallr/Cargo.toml +++ b/wallr/Cargo.toml @@ -43,3 +43,6 @@ tracing-subscriber.workspace = true # Serialization serde_yaml.workspace = true + +# High-performance global allocator for bursty decode workloads. +mimalloc = { version = "0.1", default-features = false } diff --git a/wallr/src/main.rs b/wallr/src/main.rs index 00951ea..1139d9f 100644 --- a/wallr/src/main.rs +++ b/wallr/src/main.rs @@ -1,16 +1,24 @@ #![recursion_limit = "256"] +#[global_allocator] +static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; + use anyhow::Result; use clap::Parser; use colored::Colorize; use tracing_subscriber::EnvFilter; use wallr_common::cli::{Commands, ConfigCommands, EffectArgs, IpcCommands, WallrCli}; -use wallr_common::config; +use wallr_common::config::{self, WallrConfig}; use wallr_common::effect::{Effect, FadeParams}; use wallr_core::daemon::Daemon; -use wallr_core::ipc::{IpcCommand, send_ipc_command}; +use wallr_core::ipc::{IpcCommand, IpcResponse, send_ipc_command}; use wallr_core::wallpaper::{DiagnosticStatus, WallpaperEngine}; +/// Upper bound for `wallr set -` input. It is read before any format +/// detection or staging, so an endless pipe must not grow memory without +/// limit. +const MAX_STDIN_BYTES: u64 = 256 * 1024 * 1024; + fn config_value<'a>(value: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> { key.split('.') .try_fold(value, |current, part| current.get(part)) @@ -31,10 +39,27 @@ fn set_config_value( .get_mut(*part) .ok_or_else(|| anyhow::anyhow!("unknown config key: {key}"))?; } - current[parts[parts.len() - 1]] = replacement; + let last = parts[parts.len() - 1]; + if last.is_empty() || current.get(last).is_none() { + anyhow::bail!("unknown config key: {key}"); + } + current[last] = replacement; Ok(()) } +fn ensure_response(response: IpcResponse) -> anyhow::Result { + if response.success { + Ok(response) + } else { + anyhow::bail!( + "{}", + response + .message + .unwrap_or_else(|| "daemon reported failure".to_string()) + ) + } +} + /// Combine CLI `--effect`/override flags into the transition effect. /// With no `--effect` flag the default is a plain linear crossfade, /// matching awww's default `simple` feel; explicit `-e fade` keeps polish. @@ -48,10 +73,70 @@ fn pick_effect(effect_args: &EffectArgs) -> anyhow::Result<(Effect, Option) .as_deref() .map(wallr_common::config::parse_duration) .transpose()? - .map(|d| d.as_millis() as u32); + .map(|duration| { + u32::try_from(duration.as_millis()).map_err(|_| { + anyhow::anyhow!( + "transition duration {}ms exceeds the supported maximum", + duration.as_millis() + ) + }) + }) + .transpose()?; Ok((effect, duration_ms)) } +fn detect_image_extension(bytes: &[u8]) -> &'static str { + if bytes.starts_with(b"GIF8") { + "gif" + } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "png" + } else if bytes.starts_with(b"\xff\xd8\xff") { + "jpg" + } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + "webp" + } else { + // Let the image decoder report the actual format. PNG is the common + // screenshot format and is also what grim emits. + "png" + } +} + +fn stage_stdin(bytes: &[u8], extension: &str) -> anyhow::Result { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let directory = std::env::temp_dir(); + let mut attempt = 0u32; + loop { + let path = directory.join(format!( + "wallr-stdin-{}-{}-{}.{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + attempt, + extension + )); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(mut file) => { + file.write_all(bytes)?; + file.flush()?; + return Ok(path); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + attempt = attempt.wrapping_add(1); + } + Err(err) => return Err(err.into()), + } + } +} + // All decoding, rendering, and filesystem work is dispatched through // `spawn_blocking`; a current-thread scheduler avoids keeping one idle async // worker per CPU in the long-lived daemon and keeps the IPC-only CLI light. @@ -78,8 +163,28 @@ async fn async_main() -> Result<()> { .with_env_filter(EnvFilter::new(filter)) .try_init(); - let config_path = cli.config.as_deref().map(std::path::Path::new); - let config = config::load_config(config_path)?; + let config_path_owned = cli.config.clone(); + let config_path = config_path_owned.as_deref(); + let config = match config::load_config(config_path) { + Ok(config) => config, + // `wallr config set -c new.yaml` is also how users create a fresh + // config file. Keep that one command lenient, but report a missing + // explicit file to every other command instead of silently using + // defaults with the wrong socket/paths. + Err(err) + if config_path.is_some() + && matches!( + &cli.command, + Commands::Config { + subcommand: ConfigCommands::Set { .. } + } + ) => + { + let _ = err; + WallrConfig::default() + } + Err(err) => return Err(err.into()), + }; match cli.command { Commands::Set { @@ -90,14 +195,24 @@ async fn async_main() -> Result<()> { mode, effect_args, } => { - let socket_path = config::expand_path(&config.daemon.socket); - let daemon_running = - socket_path.exists() && tokio::net::UnixStream::connect(&socket_path).await.is_ok(); + let socket_path = config::expand_path(&config.daemon.socket)?; + let daemon_running = tokio::net::UnixStream::connect(&socket_path).await.is_ok(); if !daemon_running { + if !config.daemon.auto_start { + anyhow::bail!( + "daemon is not running and daemon.auto_start is false; \ + start it with `wallr daemon`" + ); + } let exe = std::env::current_exe()?; - let _ = std::process::Command::new(&exe) - .arg("daemon") + let mut daemon_command = std::process::Command::new(&exe); + if let Some(path) = &config_path_owned { + daemon_command.arg("--config").arg(path); + } + daemon_command.arg("daemon"); + let _ = daemon_command + .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn()?; @@ -106,9 +221,7 @@ async fn async_main() -> Result<()> { loop { tokio::time::sleep(std::time::Duration::from_millis(100)).await; waited += 100; - if socket_path.exists() - && tokio::net::UnixStream::connect(&socket_path).await.is_ok() - { + if tokio::net::UnixStream::connect(&socket_path).await.is_ok() { break; } if waited >= 3000 { @@ -122,35 +235,28 @@ async fn async_main() -> Result<()> { let (effect, duration_ms) = pick_effect(&effect_args)?; - // A `-` path reads the image bytes from stdin (like `awww img -`), - // staging them in a temp file: downstream code paths key GIF - // animation and video detection off the file extension. - let path = if path.as_os_str() == "-" { + // A `-` path reads image bytes from stdin (like `awww img -`), + // staging them in a private temp file: downstream code paths key + // GIF/static decoding off the file extension. + let (path, staged) = if path.as_os_str() == "-" { use std::io::Read; let mut bytes = Vec::new(); - std::io::stdin().read_to_end(&mut bytes)?; + std::io::stdin() + .take(MAX_STDIN_BYTES + 1) + .read_to_end(&mut bytes)?; anyhow::ensure!(!bytes.is_empty(), "no image data on stdin"); - let ext = if bytes.starts_with(b"GIF8") { - "gif" - } else { - "png" - }; - let staged = std::env::temp_dir().join(format!( - "wallr-stdin-{}-{}.{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - ext - )); - std::fs::write(&staged, &bytes)?; - staged + anyhow::ensure!( + bytes.len() as u64 <= MAX_STDIN_BYTES, + "stdin image exceeds the {} MiB limit", + MAX_STDIN_BYTES / (1024 * 1024) + ); + let ext = detect_image_extension(&bytes); + (stage_stdin(&bytes, ext)?, true) } else { - path + (path, false) }; let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone()); - let resp = send_ipc_command( + let response = send_ipc_command( socket_path, IpcCommand::Preview { path: canonical_path.to_string_lossy().to_string(), @@ -162,13 +268,11 @@ async fn async_main() -> Result<()> { scaling_mode: mode.or(Some(config.wallpaper.mode.clone())), }, ) - .await?; - - if !resp.success - && let Some(msg) = resp.message - { - anyhow::bail!("{}", msg); + .await; + if staged { + let _ = std::fs::remove_file(&path); } + ensure_response(response?)?; } Commands::Doctor => { @@ -223,43 +327,43 @@ async fn async_main() -> Result<()> { }, Commands::Reload => { - let socket_path = config::expand_path(&config.daemon.socket); - if socket_path.exists() && tokio::net::UnixStream::connect(&socket_path).await.is_ok() { - let resp = send_ipc_command(socket_path, IpcCommand::Reload).await?; - if let Some(msg) = resp.message { + let socket_path = config::expand_path(&config.daemon.socket)?; + if tokio::net::UnixStream::connect(&socket_path).await.is_ok() { + let response = + ensure_response(send_ipc_command(socket_path, IpcCommand::Reload).await?)?; + if let Some(msg) = response.message { println!("{}", msg); } } else { let engine = WallpaperEngine::new(config)?; - engine.reload()?; + tokio::task::spawn_blocking(move || engine.reload_blocking()).await??; println!("{} Reloaded configuration & hooks", "✓".green()); } } Commands::Monitor { subcommand } => { use wallr_common::cli::MonitorCommands; - let socket_path = config::expand_path(&config.daemon.socket); - match subcommand { + let socket_path = config::expand_path(&config.daemon.socket)?; + let response = match subcommand { MonitorCommands::List => { - let resp = send_ipc_command(socket_path, IpcCommand::MonitorList).await?; - if let Some(msg) = resp.message { - println!("{}", msg.trim_end()); - } + send_ipc_command(socket_path, IpcCommand::MonitorList).await? } MonitorCommands::Current => { - let resp = send_ipc_command(socket_path, IpcCommand::MonitorCurrent).await?; - if let Some(msg) = resp.message { - println!("{}", msg.trim_end()); - } + send_ipc_command(socket_path, IpcCommand::MonitorCurrent).await? } + }; + let response = ensure_response(response)?; + if let Some(msg) = response.message { + println!("{}", msg.trim_end()); } } Commands::Quit => { - let socket_path = config::expand_path(&config.daemon.socket); - if socket_path.exists() && tokio::net::UnixStream::connect(&socket_path).await.is_ok() { - let resp = send_ipc_command(socket_path, IpcCommand::Stop).await?; - if let Some(msg) = resp.message { + let socket_path = config::expand_path(&config.daemon.socket)?; + if tokio::net::UnixStream::connect(&socket_path).await.is_ok() { + let response = + ensure_response(send_ipc_command(socket_path, IpcCommand::Stop).await?)?; + if let Some(msg) = response.message { println!("{}", msg); } } else { @@ -272,51 +376,24 @@ async fn async_main() -> Result<()> { if let Some(fps) = max_fps { final_config.daemon.max_fps = Some(fps); } - let daemon = Daemon::new(final_config)?; + let daemon = Daemon::with_config_path(final_config, config_path_owned.clone())?; daemon.start().await?; } Commands::Ipc { subcommand } => { - let socket_path = config::expand_path(&config.daemon.socket); + let socket_path = config::expand_path(&config.daemon.socket)?; let cmd = match subcommand { IpcCommands::Pause { monitor } => IpcCommand::Pause { monitor }, IpcCommands::Resume { monitor } => IpcCommand::Resume { monitor }, IpcCommands::Reload => IpcCommand::Reload, - IpcCommands::Preview => IpcCommand::Preview { - path: "".to_string(), - effect: None, - duration_ms: None, - no_theme: true, - theme_override: None, - monitor: None, - scaling_mode: None, - }, IpcCommands::Stop => IpcCommand::Stop, IpcCommands::Status => IpcCommand::Status, IpcCommands::Info { monitor } => IpcCommand::Info { monitor }, IpcCommands::Seek { timestamp, monitor } => { - let ms = if timestamp.contains(':') { - let parts: Vec<&str> = timestamp.split(':').collect(); - match parts.len() { - 2 => { - let min: u64 = parts[0].parse()?; - let sec: u64 = parts[1].parse()?; - (min * 60 + sec) * 1000 - } - 3 => { - let hr: u64 = parts[0].parse()?; - let min: u64 = parts[1].parse()?; - let sec: u64 = parts[2].parse()?; - (hr * 3600 + min * 60 + sec) * 1000 - } - _ => anyhow::bail!("invalid timestamp format. Use HH:MM:SS or seconds"), - } - } else { - let sec: f64 = timestamp.parse()?; - (sec * 1000.0) as u64 - }; + let timestamp_ms = wallr_common::cli::parse_timestamp_ms(×tamp) + .map_err(|e| anyhow::anyhow!(e))?; IpcCommand::Seek { - timestamp_ms: ms, + timestamp_ms, monitor, } } @@ -359,8 +436,8 @@ async fn async_main() -> Result<()> { } } }; - let resp = send_ipc_command(socket_path, cmd).await?; - if let Some(msg) = resp.message { + let response = ensure_response(send_ipc_command(socket_path, cmd).await?)?; + if let Some(msg) = response.message { println!("{}", msg); } } @@ -368,3 +445,24 @@ async fn async_main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::{detect_image_extension, set_config_value}; + + #[test] + fn detects_stdin_formats_from_magic() { + assert_eq!(detect_image_extension(b"GIF89a...."), "gif"); + assert_eq!(detect_image_extension(b"\x89PNG\r\n\x1a\n...."), "png"); + assert_eq!(detect_image_extension(b"\xff\xd8\xff...."), "jpg"); + assert_eq!(detect_image_extension(b"RIFF1234WEBP...."), "webp"); + assert_eq!(detect_image_extension(b"unknown"), "png"); + } + + #[test] + fn config_set_rejects_unknown_final_keys() { + let mut value = serde_yaml::from_str("wallpaper:\n mode: fill\n").unwrap(); + assert!(set_config_value(&mut value, "wallpaper.nope", serde_yaml::Value::Null).is_err()); + assert!(set_config_value(&mut value, "wallpaper.mode", serde_yaml::Value::Null).is_ok()); + } +}