Compare commits
8 Commits
e510e90b95
...
7b59e70192
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b59e70192 | |||
| 7f477b4ad8 | |||
| ce38b26721 | |||
| 172d7773f0 | |||
| 205ad6f646 | |||
| 936d035750 | |||
| 13d1d013e9 | |||
| b8fb3fbd6e |
@@ -0,0 +1,31 @@
|
||||
# Project-wide cargo configuration.
|
||||
#
|
||||
# Routes every rustc invocation through `sccache` so cold rebuilds and
|
||||
# fresh checkouts (CI, new dev box, after a `cargo clean`) replay
|
||||
# previously-compiled crates from a local on-disk cache rather than
|
||||
# recompiling them. Warm incremental builds still go through cargo's
|
||||
# own `target/` cache, which dominates locally — sccache buys you the
|
||||
# big wins on cold paths.
|
||||
#
|
||||
# Requires sccache on PATH. Install it once per machine:
|
||||
#
|
||||
# Arch : pacman -S sccache
|
||||
# macOS : brew install sccache
|
||||
# Cargo : cargo install sccache --locked
|
||||
#
|
||||
# Without sccache the build fails with "rustc-wrapper not found". To
|
||||
# bypass this config without editing the file, prepend
|
||||
# `RUSTC_WRAPPER= ` (empty value) to your cargo command:
|
||||
#
|
||||
# RUSTC_WRAPPER= cargo build
|
||||
#
|
||||
[build]
|
||||
rustc-wrapper = "sccache"
|
||||
|
||||
# Project-local cache so the shared dev box (or a Docker volume) keeps
|
||||
# the artefacts isolated per checkout instead of mixing them in
|
||||
# `~/.cache/sccache`. Set with `force = false` so a developer-set
|
||||
# `SCCACHE_DIR` in their shell wins — important because the sccache
|
||||
# daemon, once started, sticks with whichever directory it saw first.
|
||||
[env]
|
||||
SCCACHE_DIR = { value = ".sccache-cache", relative = true, force = false }
|
||||
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
/.sccache-cache
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
Generated
+294
-14
@@ -2429,6 +2429,12 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -2950,6 +2956,12 @@ version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "datasketches"
|
||||
version = "0.2.0"
|
||||
@@ -3477,8 +3489,15 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -3532,7 +3551,7 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3851,6 +3870,16 @@ dependencies = [
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gilrs"
|
||||
version = "0.11.1"
|
||||
@@ -4529,6 +4558,22 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imagesize"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -4856,6 +4901,17 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -6011,15 +6067,6 @@ dependencies = [
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "5.3.0"
|
||||
@@ -6165,6 +6212,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.11"
|
||||
@@ -6428,6 +6481,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.2"
|
||||
@@ -6798,6 +6857,23 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81"
|
||||
dependencies = [
|
||||
"gif",
|
||||
"image-webp",
|
||||
"log",
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.4.0"
|
||||
@@ -6808,6 +6884,15 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -6862,6 +6947,15 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.0"
|
||||
@@ -7068,6 +7162,24 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bytemuck",
|
||||
"core_maths",
|
||||
"log",
|
||||
"smallvec",
|
||||
"ttf-parser",
|
||||
"unicode-bidi-mirroring",
|
||||
"unicode-ccc",
|
||||
"unicode-properties",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.8.2"
|
||||
@@ -7123,7 +7235,7 @@ dependencies = [
|
||||
"log",
|
||||
"memmap2",
|
||||
"smithay-client-toolkit",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7382,6 +7494,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "simplecss"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simsimd"
|
||||
version = "6.5.16"
|
||||
@@ -7557,12 +7678,21 @@ dependencies = [
|
||||
"async-trait",
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"kira",
|
||||
"resvg",
|
||||
"ron",
|
||||
"serde",
|
||||
"solitaire_core",
|
||||
"solitaire_data",
|
||||
"solitaire_sync",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tiny-skia 0.12.0",
|
||||
"tokio",
|
||||
"usvg",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7860,6 +7990,9 @@ name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
@@ -7912,6 +8045,16 @@ version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb"
|
||||
|
||||
[[package]]
|
||||
name = "svgtypes"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swash"
|
||||
version = "0.2.7"
|
||||
@@ -8226,7 +8369,7 @@ checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"nom",
|
||||
"ordered-float 5.3.0",
|
||||
"ordered-float",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -8383,7 +8526,22 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png 0.18.1",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8397,6 +8555,17 @@ dependencies = [
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -8947,6 +9116,12 @@ dependencies = [
|
||||
"rand 0.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -9015,6 +9190,18 @@ version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi-mirroring"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ccc"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -9054,6 +9241,12 @@ version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-vo"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
@@ -9094,6 +9287,34 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"data-url",
|
||||
"flate2",
|
||||
"fontdb",
|
||||
"imagesize",
|
||||
"kurbo",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree 0.21.1",
|
||||
"rustybuzz",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"ttf-parser",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"unicode-vo",
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-ranges"
|
||||
version = "1.0.5"
|
||||
@@ -9453,6 +9674,12 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "27.0.1"
|
||||
@@ -9571,7 +9798,7 @@ dependencies = [
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"ordered-float 4.6.0",
|
||||
"ordered-float",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
@@ -10500,6 +10727,12 @@ version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
|
||||
[[package]]
|
||||
name = "xmlwriter"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "yazi"
|
||||
version = "0.2.1"
|
||||
@@ -10701,12 +10934,44 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
@@ -10735,6 +11000,21 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.1"
|
||||
|
||||
+24
@@ -38,6 +38,30 @@ solitaire_engine = { path = "solitaire_engine" }
|
||||
bevy = "0.18"
|
||||
kira = "0.12"
|
||||
|
||||
# SVG rasterisation pipeline for the runtime card-theme system.
|
||||
# usvg parses + simplifies; resvg renders to a tiny-skia Pixmap;
|
||||
# tiny-skia provides the CPU rasteriser. All three are maintained
|
||||
# together by the resvg-rs project and version in lockstep.
|
||||
usvg = "0.47"
|
||||
resvg = "0.47"
|
||||
tiny-skia = "0.12"
|
||||
|
||||
# Theme manifest format. RON keeps the file human-editable while
|
||||
# preserving Rust-style structures the importer can validate.
|
||||
ron = "0.12"
|
||||
|
||||
# Importer-only: reads user-supplied theme zip archives, validates
|
||||
# their contents, and unpacks them into the user themes directory.
|
||||
# Default features are disabled to keep the dependency footprint small;
|
||||
# only `deflate` is needed because the importer rejects other
|
||||
# compression methods anyway (see Phase 7 spec).
|
||||
zip = { version = "8.6", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Importer-only test dependency: tests build zip archives in a
|
||||
# scratch directory so they don't pollute the real user themes path
|
||||
# on the developer's machine.
|
||||
tempfile = "3.27"
|
||||
|
||||
axum = "0.8"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] }
|
||||
jsonwebtoken = { version = "10", default-features = false, features = ["rust_crypto"] }
|
||||
|
||||
@@ -6,11 +6,12 @@ use bevy::prelude::*;
|
||||
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||
use solitaire_engine::{
|
||||
AchievementPlugin, AnimationPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin,
|
||||
CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||
OnboardingPlugin, PausePlugin, ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin,
|
||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, TimeAttackPlugin, UiFocusPlugin,
|
||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||
CursorPlugin, DailyChallengePlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
||||
HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin,
|
||||
ProfilePlugin, ProgressPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin,
|
||||
SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin,
|
||||
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
};
|
||||
|
||||
@@ -54,7 +55,17 @@ fn main() {
|
||||
),
|
||||
};
|
||||
|
||||
App::new()
|
||||
let mut app = App::new();
|
||||
|
||||
// The card-theme system's `themes://` asset source must be
|
||||
// registered *before* `DefaultPlugins` builds `AssetPlugin`,
|
||||
// because that plugin freezes the asset-source list at build
|
||||
// time. The matching `AssetSourcesPlugin` (added below) finishes
|
||||
// the wiring after `DefaultPlugins` by populating the embedded
|
||||
// default theme into Bevy's `EmbeddedAssetRegistry`.
|
||||
register_theme_asset_sources(&mut app);
|
||||
|
||||
app
|
||||
.add_plugins(
|
||||
DefaultPlugins
|
||||
.set(WindowPlugin {
|
||||
@@ -91,6 +102,9 @@ fn main() {
|
||||
..default()
|
||||
}),
|
||||
)
|
||||
.add_plugins(AssetSourcesPlugin)
|
||||
.add_plugins(ThemePlugin)
|
||||
.add_plugins(ThemeRegistryPlugin)
|
||||
.add_plugins(FontPlugin)
|
||||
.add_plugins(GamePlugin)
|
||||
.add_plugins(TablePlugin)
|
||||
|
||||
@@ -13,6 +13,15 @@ solitaire_sync = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
resvg = { workspace = true }
|
||||
tiny-skia = { workspace = true }
|
||||
ron = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Default theme — provenance
|
||||
|
||||
This directory is the bundled-default card theme that ships embedded in
|
||||
the binary via Bevy's `embedded_asset!` macro (see
|
||||
`solitaire_engine/src/assets/sources.rs`). At runtime its files are
|
||||
addressable as `embedded://solitaire_engine/assets/themes/default/...`.
|
||||
|
||||
## Current state (Phase 3)
|
||||
|
||||
The `theme.ron` manifest in this directory lists all 52 face slots plus
|
||||
a back slot, but **the referenced SVG files do not yet exist**. The
|
||||
manifest is intentionally a stub so that:
|
||||
|
||||
1. `embedded_asset!` has a real file to bundle (the manifest itself).
|
||||
2. `ThemeManifest::validate` accepts the manifest (it requires all 52
|
||||
faces to be listed by name).
|
||||
3. The `embedded://` asset source can be source-registered and queried
|
||||
without runtime errors during Phase 3.
|
||||
|
||||
The actual SVG art will be added when the project swaps in the
|
||||
`hayeah/playing-cards-assets` artwork — see the implementation plan in
|
||||
`/CARD_PLAN.md`. At that point, every `.svg` filename listed in
|
||||
`theme.ron`'s `faces` map (and `back.svg`) must be added here, and each
|
||||
new file needs a corresponding `embedded_asset!(app, ...)` call in
|
||||
`solitaire_engine/src/assets/sources.rs::register_default_theme`.
|
||||
|
||||
## How to add files to the bundled default theme
|
||||
|
||||
For each new file you drop into this directory:
|
||||
|
||||
1. Drop the file under `solitaire_engine/assets/themes/default/`.
|
||||
2. Add one line to `register_default_theme` in
|
||||
`solitaire_engine/src/assets/sources.rs` of the form:
|
||||
```rust
|
||||
embedded_asset!(app, "../../assets/themes/default/<filename>");
|
||||
```
|
||||
(The path is relative to `sources.rs`, which lives in
|
||||
`solitaire_engine/src/assets/`.)
|
||||
3. Update this file with the licence and origin of the new asset.
|
||||
|
||||
## Licence
|
||||
|
||||
To be filled in once real artwork lands.
|
||||
@@ -0,0 +1,77 @@
|
||||
// Default card theme manifest — Phase 3 stub.
|
||||
//
|
||||
// The 53 SVG paths below are deliberate placeholders so the manifest
|
||||
// validates against `ThemeManifest::validate` (which requires all 52
|
||||
// faces plus a back). The actual SVG art lands in a later phase when
|
||||
// the swap to hayeah/playing-cards-assets is complete; until then this
|
||||
// file exists so the `embedded://` asset source has something to
|
||||
// register, and so source-registration tests have a real RON file to
|
||||
// parse.
|
||||
//
|
||||
// Suit / rank tokens follow `CardKey::manifest_name`:
|
||||
// suits: clubs, diamonds, hearts, spades
|
||||
// ranks: ace, 2, 3, 4, 5, 6, 7, 8, 9, 10, jack, queen, king
|
||||
(
|
||||
meta: (
|
||||
id: "default",
|
||||
name: "Default",
|
||||
author: "Solitaire Quest",
|
||||
version: "0.1.0",
|
||||
card_aspect: (2, 3),
|
||||
),
|
||||
back: "back.svg",
|
||||
faces: {
|
||||
"clubs_ace": "clubs_ace.svg",
|
||||
"clubs_2": "clubs_2.svg",
|
||||
"clubs_3": "clubs_3.svg",
|
||||
"clubs_4": "clubs_4.svg",
|
||||
"clubs_5": "clubs_5.svg",
|
||||
"clubs_6": "clubs_6.svg",
|
||||
"clubs_7": "clubs_7.svg",
|
||||
"clubs_8": "clubs_8.svg",
|
||||
"clubs_9": "clubs_9.svg",
|
||||
"clubs_10": "clubs_10.svg",
|
||||
"clubs_jack": "clubs_jack.svg",
|
||||
"clubs_queen": "clubs_queen.svg",
|
||||
"clubs_king": "clubs_king.svg",
|
||||
"diamonds_ace": "diamonds_ace.svg",
|
||||
"diamonds_2": "diamonds_2.svg",
|
||||
"diamonds_3": "diamonds_3.svg",
|
||||
"diamonds_4": "diamonds_4.svg",
|
||||
"diamonds_5": "diamonds_5.svg",
|
||||
"diamonds_6": "diamonds_6.svg",
|
||||
"diamonds_7": "diamonds_7.svg",
|
||||
"diamonds_8": "diamonds_8.svg",
|
||||
"diamonds_9": "diamonds_9.svg",
|
||||
"diamonds_10": "diamonds_10.svg",
|
||||
"diamonds_jack": "diamonds_jack.svg",
|
||||
"diamonds_queen": "diamonds_queen.svg",
|
||||
"diamonds_king": "diamonds_king.svg",
|
||||
"hearts_ace": "hearts_ace.svg",
|
||||
"hearts_2": "hearts_2.svg",
|
||||
"hearts_3": "hearts_3.svg",
|
||||
"hearts_4": "hearts_4.svg",
|
||||
"hearts_5": "hearts_5.svg",
|
||||
"hearts_6": "hearts_6.svg",
|
||||
"hearts_7": "hearts_7.svg",
|
||||
"hearts_8": "hearts_8.svg",
|
||||
"hearts_9": "hearts_9.svg",
|
||||
"hearts_10": "hearts_10.svg",
|
||||
"hearts_jack": "hearts_jack.svg",
|
||||
"hearts_queen": "hearts_queen.svg",
|
||||
"hearts_king": "hearts_king.svg",
|
||||
"spades_ace": "spades_ace.svg",
|
||||
"spades_2": "spades_2.svg",
|
||||
"spades_3": "spades_3.svg",
|
||||
"spades_4": "spades_4.svg",
|
||||
"spades_5": "spades_5.svg",
|
||||
"spades_6": "spades_6.svg",
|
||||
"spades_7": "spades_7.svg",
|
||||
"spades_8": "spades_8.svg",
|
||||
"spades_9": "spades_9.svg",
|
||||
"spades_10": "spades_10.svg",
|
||||
"spades_jack": "spades_jack.svg",
|
||||
"spades_queen": "spades_queen.svg",
|
||||
"spades_king": "spades_king.svg",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Asset-loading infrastructure for runtime SVG rasterisation and the
|
||||
//! per-platform user-themes directory.
|
||||
//!
|
||||
//! See `CARD_PLAN.md` for the full multi-phase implementation plan.
|
||||
//! This module is the entry point for Phases 1 (SVG → `Image`) and 5
|
||||
//! (user-themes directory). Phase 3 will extend it further with custom
|
||||
//! `AssetSource` implementations for `embedded://` and `themes://`.
|
||||
|
||||
pub mod sources;
|
||||
pub mod svg_loader;
|
||||
pub mod user_dir;
|
||||
|
||||
pub use sources::{
|
||||
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
|
||||
DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
|
||||
};
|
||||
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
|
||||
pub use user_dir::{set_user_theme_dir, user_theme_dir};
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Custom Bevy asset sources for the card-theme system.
|
||||
//!
|
||||
//! Two sources are wired up here:
|
||||
//!
|
||||
//! - **`embedded://`** — the bundled default theme. The default theme
|
||||
//! manifest (and, in later phases, every default-theme SVG) is
|
||||
//! compiled into the binary via `include_bytes!` and inserted into
|
||||
//! Bevy's [`EmbeddedAssetRegistry`] under a stable, pretty path.
|
||||
//! The default theme manifest is reachable as
|
||||
//! `embedded://solitaire_engine/assets/themes/default/theme.ron`.
|
||||
//!
|
||||
//! - **`themes://`** — user-supplied themes living in
|
||||
//! [`crate::assets::user_dir::user_theme_dir`]. Reads delegate to
|
||||
//! `FileAssetReader` rooted at that absolute path. If the directory
|
||||
//! doesn't exist yet (the common case on first run), the source
|
||||
//! still registers cleanly — individual reads simply return
|
||||
//! `NotFound` until the player drops a theme there, which is the
|
||||
//! correct behaviour for an empty user-themes directory.
|
||||
//!
|
||||
//! # Why two registration paths?
|
||||
//!
|
||||
//! Bevy treats the two sources differently:
|
||||
//!
|
||||
//! - **`themes://`** is a *new* source the engine doesn't know about.
|
||||
//! It must be registered with [`App::register_asset_source`] *before*
|
||||
//! `AssetPlugin` runs, because that plugin freezes the source list
|
||||
//! when it builds the `AssetServer`.
|
||||
//!
|
||||
//! - **`embedded://`** is already registered by `AssetPlugin` itself
|
||||
//! (via `EmbeddedAssetRegistry::register_source`). What we have to
|
||||
//! do is *populate* the registry with our default-theme files —
|
||||
//! exactly what Bevy's own `embedded_asset!` macro does. That
|
||||
//! population must happen *after* `AssetPlugin` runs, because
|
||||
//! `AssetPlugin::build` overwrites the `EmbeddedAssetRegistry`
|
||||
//! resource with a fresh empty one as part of its own setup.
|
||||
//!
|
||||
//! These two timing constraints can't be satisfied by a single
|
||||
//! `Plugin::build` call, so the public API splits the work into:
|
||||
//!
|
||||
//! 1. [`register_theme_asset_sources`] — call *before* `DefaultPlugins`,
|
||||
//! typically immediately after `App::new()`. Registers `themes://`.
|
||||
//! 2. [`AssetSourcesPlugin`] — add *after* `DefaultPlugins`. Populates
|
||||
//! the embedded default-theme files into Bevy's already-built
|
||||
//! `EmbeddedAssetRegistry`.
|
||||
//!
|
||||
//! Both must run for the card-theme system to function. The doc
|
||||
//! comments on each call out the pairing so a future reader doesn't
|
||||
//! accidentally drop one half.
|
||||
|
||||
use bevy::asset::io::embedded::EmbeddedAssetRegistry;
|
||||
use bevy::asset::io::file::FileAssetReader;
|
||||
use bevy::asset::io::AssetSourceBuilder;
|
||||
use bevy::asset::AssetApp;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::assets::user_dir::user_theme_dir;
|
||||
|
||||
/// `AssetSourceId` of the user-themes asset source. Use it as
|
||||
/// `themes://<theme_id>/theme.ron` from any code that wants to load
|
||||
/// from the user-themes directory.
|
||||
pub const USER_THEMES: &str = "themes";
|
||||
|
||||
/// Stable embedded asset URL of the bundled default theme manifest.
|
||||
///
|
||||
/// Code that wants to load the embedded default — including the future
|
||||
/// Phase 4 `ActiveTheme` initialisation — should use exactly this
|
||||
/// constant rather than re-typing the URL inline. Changing where the
|
||||
/// default theme lives in the asset graph then becomes a single-line
|
||||
/// change in this file.
|
||||
pub const DEFAULT_THEME_MANIFEST_URL: &str =
|
||||
"embedded://solitaire_engine/assets/themes/default/theme.ron";
|
||||
|
||||
/// Path the embedded default-theme manifest registers under, relative
|
||||
/// to the `embedded://` source root. Kept in lockstep with
|
||||
/// [`DEFAULT_THEME_MANIFEST_URL`] by the unit test
|
||||
/// `default_theme_url_constant_matches_embedded_path`.
|
||||
const DEFAULT_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/default/theme.ron";
|
||||
|
||||
/// Bytes of the bundled default theme manifest. Embedded at compile
|
||||
/// time via `include_bytes!` so the binary is self-contained even if
|
||||
/// the workspace's `solitaire_engine/assets/` directory is absent at
|
||||
/// runtime (e.g. when shipped to a player).
|
||||
const DEFAULT_THEME_MANIFEST_BYTES: &[u8] =
|
||||
include_bytes!("../../assets/themes/default/theme.ron");
|
||||
|
||||
/// Registers asset sources that must be in place *before*
|
||||
/// `AssetPlugin` is built.
|
||||
///
|
||||
/// In practice that means just `themes://`: `embedded://` is owned by
|
||||
/// Bevy itself and is always registered by `AssetPlugin`. To finish
|
||||
/// wiring up the embedded default theme, also add
|
||||
/// [`AssetSourcesPlugin`] *after* `DefaultPlugins`.
|
||||
///
|
||||
/// Returns the `&mut App` so the call can be chained from the binary
|
||||
/// entry point.
|
||||
pub fn register_theme_asset_sources(app: &mut App) -> &mut App {
|
||||
let root = user_theme_dir();
|
||||
app.register_asset_source(
|
||||
USER_THEMES,
|
||||
AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(root.clone()))),
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
/// Bevy `Plugin` that pushes the bundled default theme files into
|
||||
/// [`EmbeddedAssetRegistry`].
|
||||
///
|
||||
/// Add this *after* `DefaultPlugins`. It pairs with
|
||||
/// [`register_theme_asset_sources`] (which has to run *before*
|
||||
/// `DefaultPlugins`); both are required for the card-theme system to
|
||||
/// function. See the module-level doc comment for why the work has to
|
||||
/// be split across two calls.
|
||||
///
|
||||
/// To bundle additional default-theme files (the SVG art slated to
|
||||
/// land in a later phase), edit [`populate_embedded_default_theme`].
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AssetSourcesPlugin;
|
||||
|
||||
impl Plugin for AssetSourcesPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
populate_embedded_default_theme(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes every bundled default-theme file into the
|
||||
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a
|
||||
/// free function (and not inside the `Plugin::build` body) means the
|
||||
/// unit test below can exercise it without spinning up a full Bevy
|
||||
/// `App` with `AssetPlugin`.
|
||||
///
|
||||
/// **Adding files to the bundled default theme** is a single edit
|
||||
/// per file: add an `include_bytes!` constant that points at the file
|
||||
/// under `solitaire_engine/assets/themes/default/`, then add a
|
||||
/// matching `registry.insert_asset(...)` call here. Keep the
|
||||
/// `asset_path` argument exactly the relative path that the manifest
|
||||
/// references (e.g. `solitaire_engine/assets/themes/default/back.svg`).
|
||||
pub fn populate_embedded_default_theme(app: &mut App) {
|
||||
let registry = app
|
||||
.world_mut()
|
||||
.get_resource_or_insert_with(EmbeddedAssetRegistry::default);
|
||||
|
||||
// `full_path` is only consulted by the optional
|
||||
// `embedded_watcher` cargo feature (which we don't enable). Use
|
||||
// the manifest's logical workspace path so a future debugger
|
||||
// session sees a sensible source-of-truth string.
|
||||
registry.insert_asset(
|
||||
std::path::PathBuf::from(DEFAULT_THEME_MANIFEST_PATH),
|
||||
std::path::Path::new(DEFAULT_THEME_MANIFEST_PATH),
|
||||
DEFAULT_THEME_MANIFEST_BYTES,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `register_theme_asset_sources` must register `themes://`
|
||||
/// without panicking, even when `user_theme_dir()` resolves to a
|
||||
/// directory that doesn't exist on disk — Bevy's
|
||||
/// `FileAssetReader` constructs lazily, so a missing root is
|
||||
/// fine.
|
||||
#[test]
|
||||
fn register_theme_asset_sources_inserts_themes_source() {
|
||||
let mut app = App::new();
|
||||
register_theme_asset_sources(&mut app);
|
||||
|
||||
let mut sources = app
|
||||
.world_mut()
|
||||
.get_resource_or_init::<bevy::asset::io::AssetSourceBuilders>();
|
||||
assert!(
|
||||
sources.get_mut(USER_THEMES).is_some(),
|
||||
"themes:// source not registered"
|
||||
);
|
||||
}
|
||||
|
||||
/// `populate_embedded_default_theme` must work as a drop-in step
|
||||
/// regardless of whether `EmbeddedAssetRegistry` already exists,
|
||||
/// so it can be called both from `AssetSourcesPlugin::build`
|
||||
/// (after `AssetPlugin` initialised it) and from this test (which
|
||||
/// uses the resource's `get_resource_or_insert_with` fallback).
|
||||
#[test]
|
||||
fn populate_embedded_default_theme_runs_without_asset_plugin() {
|
||||
let mut app = App::new();
|
||||
populate_embedded_default_theme(&mut app);
|
||||
|
||||
// Resource exists and has been inserted into.
|
||||
assert!(app
|
||||
.world()
|
||||
.get_resource::<EmbeddedAssetRegistry>()
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// The bundled default theme stub must satisfy
|
||||
/// `ThemeManifest::validate` — otherwise the embedded source
|
||||
/// would register a manifest the loader will then reject at
|
||||
/// runtime.
|
||||
#[test]
|
||||
fn embedded_default_theme_manifest_validates() {
|
||||
use crate::theme::ThemeManifest;
|
||||
|
||||
let manifest: ThemeManifest = ron::de::from_bytes(DEFAULT_THEME_MANIFEST_BYTES)
|
||||
.expect("default manifest must parse as RON");
|
||||
let faces = manifest
|
||||
.validate()
|
||||
.expect("default manifest must list all 52 faces");
|
||||
assert_eq!(faces.len(), 52);
|
||||
}
|
||||
|
||||
/// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH`
|
||||
/// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa)
|
||||
/// the asset would register at one path and be loaded from
|
||||
/// another. Pin them together in the test suite so any drift
|
||||
/// fails CI.
|
||||
#[test]
|
||||
fn default_theme_url_constant_matches_embedded_path() {
|
||||
let url_tail = DEFAULT_THEME_MANIFEST_URL
|
||||
.strip_prefix("embedded://")
|
||||
.expect("default theme URL must use embedded:// scheme");
|
||||
assert_eq!(url_tail, DEFAULT_THEME_MANIFEST_PATH);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Bevy `AssetLoader` that rasterises an SVG into `bevy::image::Image`.
|
||||
//!
|
||||
//! The card-theme system (see `CARD_PLAN.md`) ships SVG sources both as
|
||||
//! the embedded default theme and as user-supplied themes. Bevy 0.18 has
|
||||
//! no built-in SVG support, so this loader bridges `usvg` (parser) +
|
||||
//! `resvg` (renderer) + `tiny-skia` (CPU pixmap) to produce textures
|
||||
//! that the rest of the engine consumes as plain `Handle<Image>` — no
|
||||
//! awareness of vector graphics leaks past this boundary.
|
||||
//!
|
||||
//! Rasterisation happens once per (asset, settings) pair at load time.
|
||||
//! Bevy's asset system caches the resulting `Image`, so the cost is paid
|
||||
//! exactly once per theme switch, not per frame.
|
||||
//!
|
||||
//! # Settings
|
||||
//!
|
||||
//! Each `Handle<Image>` produced via this loader carries
|
||||
//! [`SvgLoaderSettings`]. The most important field is `target_size` —
|
||||
//! callers should specify the rasterisation resolution explicitly when
|
||||
//! loading via `load_with_settings(...)`. The default of 512×768 is a
|
||||
//! safe fallback that fits a typical 2:3 playing card.
|
||||
|
||||
use bevy::asset::io::Reader;
|
||||
use bevy::asset::{AssetLoader, LoadContext, RenderAssetUsages};
|
||||
use bevy::image::Image;
|
||||
use bevy::math::UVec2;
|
||||
use bevy::reflect::TypePath;
|
||||
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Per-asset settings consumed by [`SvgLoader::load`].
|
||||
///
|
||||
/// `target_size` controls the rasterisation resolution. SVG content is
|
||||
/// scaled uniformly to fit this box while preserving aspect ratio.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct SvgLoaderSettings {
|
||||
/// Output texture dimensions in pixels.
|
||||
pub target_size: UVec2,
|
||||
}
|
||||
|
||||
impl Default for SvgLoaderSettings {
|
||||
fn default() -> Self {
|
||||
// 512×768 is a 2:3 aspect at a resolution that stays sharp on
|
||||
// typical desktop windows where individual cards never exceed
|
||||
// ~250 px wide. Callers that need higher fidelity should
|
||||
// override via `load_with_settings`.
|
||||
Self {
|
||||
target_size: UVec2::new(512, 768),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced by [`SvgLoader::load`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SvgLoaderError {
|
||||
/// The asset reader failed before the SVG bytes were consumed.
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// `usvg` rejected the input as malformed or unsupported.
|
||||
#[error("svg parse: {0}")]
|
||||
Parse(#[from] usvg::Error),
|
||||
/// `tiny_skia::Pixmap::new` returned `None` — typically because the
|
||||
/// requested target_size is zero or absurdly large.
|
||||
#[error("could not allocate pixmap of size {0}x{1}")]
|
||||
PixmapAlloc(u32, u32),
|
||||
}
|
||||
|
||||
/// `AssetLoader` registered for the `.svg` extension.
|
||||
///
|
||||
/// Stateless; safe to construct via `Default` and register once at
|
||||
/// startup with `app.register_asset_loader(SvgLoader)`.
|
||||
#[derive(Debug, Default, TypePath)]
|
||||
pub struct SvgLoader;
|
||||
|
||||
impl AssetLoader for SvgLoader {
|
||||
type Asset = Image;
|
||||
type Settings = SvgLoaderSettings;
|
||||
type Error = SvgLoaderError;
|
||||
|
||||
async fn load(
|
||||
&self,
|
||||
reader: &mut dyn Reader,
|
||||
settings: &Self::Settings,
|
||||
_load_context: &mut LoadContext<'_>,
|
||||
) -> Result<Image, Self::Error> {
|
||||
let mut bytes = Vec::new();
|
||||
reader.read_to_end(&mut bytes).await?;
|
||||
rasterize_svg(&bytes, settings.target_size)
|
||||
}
|
||||
|
||||
fn extensions(&self) -> &[&str] {
|
||||
&["svg"]
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterises an SVG byte buffer into an `Image` of exactly
|
||||
/// `target.x × target.y` pixels. Content is scaled uniformly to fit
|
||||
/// while preserving aspect ratio; unused area is left transparent.
|
||||
///
|
||||
/// Exposed separately from the `AssetLoader` impl so callers (tests,
|
||||
/// the Phase 7 zip importer's "is this a valid SVG?" check, future
|
||||
/// thumbnail generators) can rasterise without going through the
|
||||
/// asset graph.
|
||||
pub fn rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, SvgLoaderError> {
|
||||
let opt = usvg::Options::default();
|
||||
let tree = usvg::Tree::from_data(svg_bytes, &opt)?;
|
||||
|
||||
let svg_size = tree.size();
|
||||
let svg_w = svg_size.width();
|
||||
let svg_h = svg_size.height();
|
||||
|
||||
let target_w = target.x as f32;
|
||||
let target_h = target.y as f32;
|
||||
|
||||
// Scale-to-fit while preserving aspect — the smaller axis ratio wins
|
||||
// so the entire SVG is visible inside the target box.
|
||||
let scale = (target_w / svg_w).min(target_h / svg_h);
|
||||
|
||||
let mut pixmap = tiny_skia::Pixmap::new(target.x, target.y)
|
||||
.ok_or(SvgLoaderError::PixmapAlloc(target.x, target.y))?;
|
||||
|
||||
// Centre the scaled SVG inside the target box so any aspect-ratio
|
||||
// mismatch is balanced rather than pinned to the top-left corner.
|
||||
let dx = (target_w - svg_w * scale) * 0.5;
|
||||
let dy = (target_h - svg_h * scale) * 0.5;
|
||||
let transform = tiny_skia::Transform::from_scale(scale, scale).post_translate(dx, dy);
|
||||
|
||||
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||
|
||||
Ok(Image::new(
|
||||
Extent3d {
|
||||
width: target.x,
|
||||
height: target.y,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
pixmap.take(),
|
||||
TextureFormat::Rgba8UnormSrgb,
|
||||
RenderAssetUsages::default(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimal but non-trivial SVG: yellow rectangle + dark circle.
|
||||
/// Embedded inline so tests have no filesystem dependencies. The
|
||||
/// `##` raw-string delimiter lets us inline `#`-prefixed hex colours.
|
||||
const TEST_SVG: &[u8] = br##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
||||
<rect x="0" y="0" width="200" height="300" fill="#FFD23F"/>
|
||||
<circle cx="100" cy="150" r="80" fill="#1A0F2E"/>
|
||||
</svg>"##;
|
||||
|
||||
#[test]
|
||||
fn rasterizes_at_default_size() {
|
||||
let settings = SvgLoaderSettings::default();
|
||||
let image = rasterize_svg(TEST_SVG, settings.target_size).expect("rasterisation");
|
||||
assert_eq!(image.size().x, 512);
|
||||
assert_eq!(image.size().y, 768);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterizes_at_custom_size() {
|
||||
let image = rasterize_svg(TEST_SVG, UVec2::new(64, 96)).expect("rasterisation");
|
||||
assert_eq!(image.size().x, 64);
|
||||
assert_eq!(image.size().y, 96);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_dimension() {
|
||||
let err = rasterize_svg(TEST_SVG, UVec2::new(0, 100)).unwrap_err();
|
||||
assert!(matches!(err, SvgLoaderError::PixmapAlloc(0, 100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_svg() {
|
||||
let err = rasterize_svg(b"not actually svg", UVec2::new(64, 96)).unwrap_err();
|
||||
assert!(matches!(err, SvgLoaderError::Parse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixmap_data_is_rgba_with_target_byte_count() {
|
||||
let image =
|
||||
rasterize_svg(TEST_SVG, UVec2::new(32, 48)).expect("rasterisation");
|
||||
let pixels = image.data.as_ref().expect("rasterised image carries pixel data");
|
||||
// 32 × 48 × 4 (RGBA bytes) = 6144 bytes
|
||||
assert_eq!(pixels.len(), 32 * 48 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_advertises_svg_extension() {
|
||||
let loader = SvgLoader;
|
||||
assert_eq!(loader.extensions(), &["svg"]);
|
||||
}
|
||||
|
||||
/// Compile-time guard that `SvgLoaderSettings` satisfies the trait
|
||||
/// bounds Bevy expects on `AssetLoader::Settings` — keeps the
|
||||
/// loader's `#[derive]` set honest if the upstream signature ever
|
||||
/// tightens.
|
||||
#[test]
|
||||
fn settings_satisfies_loader_bounds() {
|
||||
fn assert_loader_settings<T: Default + serde::Serialize + serde::de::DeserializeOwned>() {}
|
||||
assert_loader_settings::<SvgLoaderSettings>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Per-platform resolution of the user-themes directory.
|
||||
//!
|
||||
//! The path is determined exactly once and exposed via
|
||||
//! [`user_theme_dir`]. On desktop platforms it is derived from
|
||||
//! `dirs::data_dir()` (matching the rest of the project's
|
||||
//! per-app-storage convention); on mobile it must be supplied by the
|
||||
//! platform entry point via [`set_user_theme_dir`] before any code
|
||||
//! that needs the path executes — there is deliberately no silent
|
||||
//! fallback because mobile sandboxing makes any guess we'd hard-code
|
||||
//! wrong.
|
||||
//!
|
||||
//! # Why panic instead of returning Result?
|
||||
//!
|
||||
//! User-theme resolution is bootstrap-time configuration, not game
|
||||
//! logic, so per CLAUDE.md panics are acceptable here. Returning
|
||||
//! `Result` would force every caller (the registry, the asset source,
|
||||
//! the importer) to plumb an error through systems that have no
|
||||
//! recovery path: there is no useful state to display if we can't
|
||||
//! find the user themes directory at all.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Override slot populated by mobile entry points (Android's
|
||||
/// `android_main`, iOS's launch handler) before the Bevy `App` starts.
|
||||
/// Desktop platforms ignore the override and fall through to
|
||||
/// [`desktop_theme_dir`].
|
||||
static USER_THEME_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Sub-folder under `dirs::data_dir()` where the project keeps every
|
||||
/// per-user file. Matches the existing convention used by
|
||||
/// `solitaire_data` for `settings.json`, `stats.json`, etc.
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
|
||||
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
|
||||
const THEME_DIR_NAME: &str = "themes";
|
||||
|
||||
/// Sets the user-themes directory at runtime — mobile-only API.
|
||||
///
|
||||
/// Returns `Err` containing the rejected path if the override has
|
||||
/// already been set. The first caller wins and subsequent calls are
|
||||
/// silently a no-op-with-feedback so a mis-configured embedder can't
|
||||
/// flip the path mid-session.
|
||||
///
|
||||
/// On desktop platforms this is functional but unnecessary —
|
||||
/// [`user_theme_dir`] derives the path from `dirs::data_dir` directly
|
||||
/// and ignores the override. Setting it on desktop is harmless but
|
||||
/// nearly always a sign of confusion.
|
||||
pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
|
||||
USER_THEME_DIR_OVERRIDE.set(path)
|
||||
}
|
||||
|
||||
/// Returns the absolute path of the user-themes directory on the
|
||||
/// current platform.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics on:
|
||||
///
|
||||
/// - Desktop, if `dirs::data_dir()` returns `None` (rare; usually
|
||||
/// indicates a broken `$HOME` or `$XDG_*` configuration).
|
||||
/// - Mobile, if no entry point has called [`set_user_theme_dir`] yet.
|
||||
/// - Any other target, where the embedder is required to supply the
|
||||
/// path manually.
|
||||
///
|
||||
/// The panic message names the missing piece so the failure is
|
||||
/// immediately actionable.
|
||||
pub fn user_theme_dir() -> PathBuf {
|
||||
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
||||
return p.clone();
|
||||
}
|
||||
user_theme_dir_for(detected_platform_data_dir())
|
||||
}
|
||||
|
||||
/// Composition helper that takes the platform data dir as input so the
|
||||
/// pure path-joining behaviour is unit-testable without depending on
|
||||
/// the user's actual `$HOME`.
|
||||
fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
||||
data_dir.join(APP_DIR_NAME).join(THEME_DIR_NAME)
|
||||
}
|
||||
|
||||
/// Per-target-os resolution of the platform's data dir. Split out so
|
||||
/// mobile branches can grow without disturbing desktop behaviour.
|
||||
fn detected_platform_data_dir() -> PathBuf {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
{
|
||||
dirs::data_dir().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"user_theme_dir(): platform data directory is unavailable. \
|
||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
||||
the OS reported no Application Support / AppData path. \
|
||||
As a workaround call solitaire_engine::assets::user_dir::\
|
||||
set_user_theme_dir() before App::run()."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
{
|
||||
panic!(
|
||||
"user_theme_dir(): mobile entry point must call \
|
||||
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
||||
before App::run() — there is no platform default."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "windows",
|
||||
target_os = "android",
|
||||
target_os = "ios"
|
||||
)))]
|
||||
{
|
||||
panic!(
|
||||
"user_theme_dir(): unsupported platform; call \
|
||||
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
||||
from your entry point before App::run()."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_theme_dir_for_appends_solitaire_quest_themes() {
|
||||
let dir = user_theme_dir_for(PathBuf::from("/tmp/data"));
|
||||
assert_eq!(
|
||||
dir,
|
||||
PathBuf::from("/tmp/data/solitaire_quest/themes"),
|
||||
"user dir must nest under solitaire_quest/themes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_theme_dir_for_handles_empty_root() {
|
||||
let dir = user_theme_dir_for(PathBuf::new());
|
||||
assert_eq!(dir, PathBuf::from("solitaire_quest/themes"));
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||
#[test]
|
||||
fn detected_data_dir_yields_a_path_with_a_parent() {
|
||||
// On every supported desktop platform the OS reports a
|
||||
// user-writable data directory; the test machine already has
|
||||
// one for `dirs::data_dir()` to discover. We don't pin the
|
||||
// exact value because it depends on the user's $HOME, but it
|
||||
// must at least be a non-empty path with a parent component.
|
||||
let dir = detected_platform_data_dir();
|
||||
assert!(dir.parent().is_some(), "data dir {dir:?} should be absolute");
|
||||
}
|
||||
|
||||
// The OnceLock-based override is intentionally NOT covered here:
|
||||
// setting it once would pollute every subsequent test in the
|
||||
// process that called `user_theme_dir()`. The override's
|
||||
// first-write-wins semantics come from `std::sync::OnceLock` which
|
||||
// is already well-tested upstream; the behaviour we add on top is
|
||||
// a trivial early-return that's covered by code review.
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Bevy integration layer for Solitaire Quest.
|
||||
|
||||
pub mod assets;
|
||||
pub mod card_animation;
|
||||
pub mod achievement_plugin;
|
||||
pub mod animation_plugin;
|
||||
@@ -30,6 +31,7 @@ pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
pub mod sync_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod theme;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod ui_focus;
|
||||
pub mod ui_modal;
|
||||
@@ -38,6 +40,14 @@ pub mod ui_tooltip;
|
||||
pub mod weekly_goals_plugin;
|
||||
pub mod win_summary_plugin;
|
||||
|
||||
pub use assets::{
|
||||
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
|
||||
DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
|
||||
};
|
||||
pub use theme::{
|
||||
set_theme, ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
||||
ThemeRegistryPlugin,
|
||||
};
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
pub use challenge_plugin::{
|
||||
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
//! Theme zip-archive importer.
|
||||
//!
|
||||
//! Phase 7 of the card-theme system (see `CARD_PLAN.md`). Players ship
|
||||
//! and install third-party themes as a single `.zip` containing a
|
||||
//! `theme.ron` manifest at the archive root plus the 52 face SVGs and
|
||||
//! one back SVG referenced by that manifest. [`import_theme`] is the
|
||||
//! one-shot entry point: it opens the zip, validates structurally,
|
||||
//! then atomically unpacks into [`crate::assets::user_theme_dir`] —
|
||||
//! never touching the user's themes directory unless every check
|
||||
//! passes.
|
||||
//!
|
||||
//! # Safety guarantees
|
||||
//!
|
||||
//! - **Hard size cap.** The total declared archive size must not
|
||||
//! exceed 20 MB. The cap is checked from the central directory
|
||||
//! (i.e. before any extraction) so a zip-bomb cannot run us out of
|
||||
//! memory or disk just by pretending to be small.
|
||||
//! - **Zip-slip immune.** Every entry path is normalised and rejected
|
||||
//! if it contains `..`, an absolute prefix, or any non-`Normal`
|
||||
//! component. We never trust the OS to clamp `..` for us.
|
||||
//! - **Manifest-driven.** Only paths the manifest declares are
|
||||
//! extracted; every face/back path is also rasterised through
|
||||
//! [`crate::assets::rasterize_svg`] as a structural validity check
|
||||
//! so corrupt SVGs surface here, not in the asset graph.
|
||||
//! - **Atomic install.** Extraction goes to a sibling temp directory
|
||||
//! that's renamed into place only after every byte has been
|
||||
//! written. A failed import leaves the user themes dir untouched.
|
||||
//! - **No id collision.** If the manifest's `meta.id` already names a
|
||||
//! directory in the user themes root, we abort before writing.
|
||||
//! Replacing an existing theme is a deliberate user action handled
|
||||
//! by Phase 6's registry, not a side effect of importing.
|
||||
//!
|
||||
//! # Testing hook
|
||||
//!
|
||||
//! Tests target [`import_theme_into`] directly so they can pass a
|
||||
//! `tempfile::TempDir` as the destination root. The public
|
||||
//! [`import_theme`] is a thin wrapper that resolves the destination
|
||||
//! via [`crate::assets::user_theme_dir`].
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use bevy::math::UVec2;
|
||||
|
||||
use crate::assets::{rasterize_svg, user_theme_dir, SvgLoaderError};
|
||||
|
||||
use super::manifest::{ManifestError, ThemeManifest};
|
||||
use super::ThemeMetaError;
|
||||
|
||||
/// Hard cap on the *uncompressed* total of all archive entries. Set
|
||||
/// generously high relative to a realistic 53-SVG theme (~1–2 MB at
|
||||
/// most for vector content) but firmly below anything we'd risk
|
||||
/// extracting blind.
|
||||
pub const MAX_ARCHIVE_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
/// Tiny rasterisation target used purely to validate SVG structure.
|
||||
/// The actual asset loader picks a real size at load time; we just
|
||||
/// need `usvg` + `resvg` to accept the bytes here.
|
||||
const SVG_VALIDATION_SIZE: UVec2 = UVec2::new(64, 96);
|
||||
|
||||
/// Filename of the manifest at the archive root. Must match what
|
||||
/// `CardThemeLoader::extensions()` advertises so the same artefact
|
||||
/// works for both import and load.
|
||||
const MANIFEST_NAME: &str = "theme.ron";
|
||||
|
||||
/// Strongly-typed wrapper around a successfully imported theme's
|
||||
/// manifest id. Consumers (notably the Phase 6 registry refresh)
|
||||
/// receive this so they can route directly to the new theme without
|
||||
/// re-parsing the manifest off disk.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ThemeId(pub String);
|
||||
|
||||
impl ThemeId {
|
||||
/// Borrow the underlying id as `&str` for path joining and lookups.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors surfaced by [`import_theme`] / [`import_theme_into`].
|
||||
///
|
||||
/// Each variant pinpoints exactly which check rejected the archive
|
||||
/// — the importer never lumps unrelated failures into a single
|
||||
/// generic error so the caller can render a precise message in the UI.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ImportError {
|
||||
/// The zip file could not be opened or its central directory was
|
||||
/// unreadable. `zip::result::ZipError` covers both I/O failures
|
||||
/// and structurally corrupt archives.
|
||||
#[error("could not open zip archive: {0}")]
|
||||
OpenArchive(#[from] zip::result::ZipError),
|
||||
|
||||
/// Filesystem failure outside of zip parsing (creating the temp
|
||||
/// dir, writing extracted bytes, the final rename, etc).
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
/// The archive's declared total uncompressed size exceeds
|
||||
/// [`MAX_ARCHIVE_BYTES`]. Checked *before* extraction.
|
||||
#[error(
|
||||
"archive declares {total} uncompressed bytes, exceeds the {limit}-byte limit"
|
||||
)]
|
||||
Oversized { total: u64, limit: u64 },
|
||||
|
||||
/// No `theme.ron` at the archive root.
|
||||
#[error("archive does not contain `theme.ron` at the root")]
|
||||
MissingManifest,
|
||||
|
||||
/// `theme.ron` present but couldn't be parsed as RON.
|
||||
#[error("manifest parse (RON): {0}")]
|
||||
ManifestParse(#[from] ron::error::SpannedError),
|
||||
|
||||
/// Manifest parsed but failed structural validation. Wraps the
|
||||
/// 52-faces / unknown-key / duplicate-key diagnostics from
|
||||
/// [`super::manifest`].
|
||||
#[error("manifest validation: {0}")]
|
||||
Validation(#[from] ManifestError),
|
||||
|
||||
/// Manifest's `meta` block failed validation in isolation. Kept
|
||||
/// distinct from the above so callers can branch on metadata-only
|
||||
/// problems (id shape, aspect zero, etc.) when wanted.
|
||||
#[error("manifest meta: {0}")]
|
||||
Meta(#[from] ThemeMetaError),
|
||||
|
||||
/// A face or back path declared in the manifest is not present in
|
||||
/// the zip's entry list.
|
||||
#[error("manifest references file not present in archive: {missing}")]
|
||||
MissingFile { missing: String },
|
||||
|
||||
/// One of the referenced SVGs failed to rasterise — `usvg`
|
||||
/// rejected it, the bytes were truncated, etc.
|
||||
#[error("invalid SVG content for {path}: {source}")]
|
||||
InvalidSvg {
|
||||
path: String,
|
||||
#[source]
|
||||
source: SvgLoaderError,
|
||||
},
|
||||
|
||||
/// A zip entry's normalised path escapes the archive root (zip
|
||||
/// slip): contains `..`, is absolute, or names something other
|
||||
/// than a normal file component.
|
||||
#[error("zip-slip path traversal attempt: {path}")]
|
||||
ZipSlip { path: String },
|
||||
|
||||
/// The manifest's `meta.id` already names a directory under the
|
||||
/// user themes root.
|
||||
#[error("a theme with id {id:?} is already installed")]
|
||||
IdCollision { id: String },
|
||||
}
|
||||
|
||||
/// Imports a theme zip into the per-platform user themes directory
|
||||
/// resolved by [`crate::assets::user_theme_dir`].
|
||||
///
|
||||
/// Returns the imported theme's manifest id on success. The Phase 6
|
||||
/// registry is responsible for refreshing its in-memory list — this
|
||||
/// function only writes to disk.
|
||||
///
|
||||
/// See the module-level docs for the full safety contract.
|
||||
pub fn import_theme(zip_path: &Path) -> Result<ThemeId, ImportError> {
|
||||
import_theme_into(zip_path, &user_theme_dir())
|
||||
}
|
||||
|
||||
/// Same as [`import_theme`] but takes the destination root explicitly.
|
||||
///
|
||||
/// Tests use this directly with a `tempfile::TempDir` so they can
|
||||
/// exercise the full extraction path without touching the global
|
||||
/// [`crate::assets::user_dir::set_user_theme_dir`] override.
|
||||
pub fn import_theme_into(
|
||||
zip_path: &Path,
|
||||
target_root: &Path,
|
||||
) -> Result<ThemeId, ImportError> {
|
||||
let file = File::open(zip_path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)?;
|
||||
|
||||
enforce_archive_size_limit(&mut archive)?;
|
||||
enforce_zip_slip_safe(&mut archive)?;
|
||||
|
||||
// Parse + validate the manifest in-memory. Anything below this
|
||||
// line works against a known-good `ThemeManifest`.
|
||||
let manifest = read_manifest(&mut archive)?;
|
||||
let face_paths = manifest.validate()?;
|
||||
|
||||
// Confirm every referenced SVG is in the archive AND structurally
|
||||
// valid before we commit to writing anything to disk.
|
||||
let mut required: Vec<PathBuf> = face_paths.values().cloned().collect();
|
||||
required.push(manifest.back.clone());
|
||||
for path in &required {
|
||||
let bytes = read_archive_entry(&mut archive, path)?;
|
||||
rasterize_svg(&bytes, SVG_VALIDATION_SIZE).map_err(|source| {
|
||||
ImportError::InvalidSvg {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
let id = manifest.meta.id.clone();
|
||||
let final_dir = target_root.join(&id);
|
||||
if final_dir.exists() {
|
||||
return Err(ImportError::IdCollision { id });
|
||||
}
|
||||
|
||||
// Stage the extraction in a sibling temp dir so a partial write
|
||||
// never reaches `final_dir`. We rename on success; on failure
|
||||
// we wipe the staging dir without ever touching `final_dir`.
|
||||
fs::create_dir_all(target_root)?;
|
||||
let staging = target_root.join(format!(".{id}.tmp"));
|
||||
if staging.exists() {
|
||||
// Leftover from a previous crashed import; safe to remove
|
||||
// because it never made the rename.
|
||||
fs::remove_dir_all(&staging)?;
|
||||
}
|
||||
fs::create_dir_all(&staging)?;
|
||||
|
||||
let extract_result = (|| -> Result<(), ImportError> {
|
||||
// Always extract the manifest under its canonical name.
|
||||
write_archive_entry(&mut archive, MANIFEST_NAME, &staging)?;
|
||||
for path in &required {
|
||||
write_archive_entry_pathbuf(&mut archive, path, &staging)?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match extract_result {
|
||||
Ok(()) => match install_atomic(&staging, &final_dir) {
|
||||
Ok(()) => Ok(ThemeId(id)),
|
||||
Err(e) => {
|
||||
// Best-effort cleanup; preserve the original error.
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
Err(e)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&staging);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sums every entry's declared uncompressed size and rejects archives
|
||||
/// that overflow [`MAX_ARCHIVE_BYTES`]. Iterates the central
|
||||
/// directory only — does not actually decompress anything.
|
||||
fn enforce_archive_size_limit<R: io::Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
) -> Result<(), ImportError> {
|
||||
let mut total: u64 = 0;
|
||||
for i in 0..archive.len() {
|
||||
let entry = archive.by_index_raw(i)?;
|
||||
total = total.saturating_add(entry.size());
|
||||
if total > MAX_ARCHIVE_BYTES {
|
||||
return Err(ImportError::Oversized {
|
||||
total,
|
||||
limit: MAX_ARCHIVE_BYTES,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walks every entry name and rejects the archive if any path
|
||||
/// (after normalisation) escapes its root. Catches `..`, absolute
|
||||
/// paths, drive prefixes on Windows, and the awkward case where
|
||||
/// `enclosed_name` returns `None` because the entry is suspicious.
|
||||
fn enforce_zip_slip_safe<R: io::Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
) -> Result<(), ImportError> {
|
||||
for i in 0..archive.len() {
|
||||
let entry = archive.by_index_raw(i)?;
|
||||
let name = entry.name().to_owned();
|
||||
let normalised = entry
|
||||
.enclosed_name()
|
||||
.ok_or_else(|| ImportError::ZipSlip { path: name.clone() })?;
|
||||
if !is_safe_relative_path(&normalised) {
|
||||
return Err(ImportError::ZipSlip { path: name });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True iff `p` is a relative path consisting only of `Normal`
|
||||
/// components — no root, no prefix, no `.` or `..`. The zip crate's
|
||||
/// `enclosed_name` already strips most attacks; this is belt and
|
||||
/// braces.
|
||||
fn is_safe_relative_path(p: &Path) -> bool {
|
||||
if p.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
p.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
}
|
||||
|
||||
/// Reads `theme.ron` from the archive root and parses it.
|
||||
///
|
||||
/// Errors:
|
||||
/// - [`ImportError::MissingManifest`] when the archive has no
|
||||
/// `theme.ron` entry at its root.
|
||||
/// - [`ImportError::ManifestParse`] when the bytes don't form valid
|
||||
/// RON for `ThemeManifest`.
|
||||
fn read_manifest<R: io::Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
) -> Result<ThemeManifest, ImportError> {
|
||||
// We can't use `?` directly across `by_name` because a missing
|
||||
// file is a domain-level error here, not a generic ZipError.
|
||||
let bytes = match archive.by_name(MANIFEST_NAME) {
|
||||
Ok(mut entry) => {
|
||||
let mut buf = Vec::with_capacity(entry.size() as usize);
|
||||
entry.read_to_end(&mut buf)?;
|
||||
buf
|
||||
}
|
||||
Err(zip::result::ZipError::FileNotFound) => {
|
||||
return Err(ImportError::MissingManifest);
|
||||
}
|
||||
Err(e) => return Err(ImportError::OpenArchive(e)),
|
||||
};
|
||||
|
||||
let manifest: ThemeManifest = ron::de::from_bytes(&bytes)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Reads a manifest-declared entry's bytes by `Path`, normalising the
|
||||
/// separators so a manifest authored on Windows still resolves on
|
||||
/// Unix and vice versa.
|
||||
///
|
||||
/// Returns [`ImportError::MissingFile`] when the archive has no entry
|
||||
/// matching the path.
|
||||
fn read_archive_entry<R: io::Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
path: &Path,
|
||||
) -> Result<Vec<u8>, ImportError> {
|
||||
let key = archive_key(path);
|
||||
let mut entry = match archive.by_name(&key) {
|
||||
Ok(e) => e,
|
||||
Err(zip::result::ZipError::FileNotFound) => {
|
||||
return Err(ImportError::MissingFile { missing: key });
|
||||
}
|
||||
Err(e) => return Err(ImportError::OpenArchive(e)),
|
||||
};
|
||||
let mut buf = Vec::with_capacity(entry.size() as usize);
|
||||
entry.read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Joins manifest-declared sub-paths with the archive's root using
|
||||
/// forward slashes — zip uses `/` on every platform.
|
||||
fn archive_key(path: &Path) -> String {
|
||||
path.components()
|
||||
.filter_map(|c| match c {
|
||||
Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
/// Extracts a single entry under the staging directory, creating any
|
||||
/// parent directories as needed. The destination path is rebuilt from
|
||||
/// the safe components we already vetted in
|
||||
/// [`enforce_zip_slip_safe`], not from the raw entry name.
|
||||
fn write_archive_entry<R: io::Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
name: &str,
|
||||
staging: &Path,
|
||||
) -> Result<(), ImportError> {
|
||||
let mut entry = match archive.by_name(name) {
|
||||
Ok(e) => e,
|
||||
Err(zip::result::ZipError::FileNotFound) => {
|
||||
return Err(ImportError::MissingFile {
|
||||
missing: name.to_owned(),
|
||||
});
|
||||
}
|
||||
Err(e) => return Err(ImportError::OpenArchive(e)),
|
||||
};
|
||||
let safe = entry
|
||||
.enclosed_name()
|
||||
.ok_or_else(|| ImportError::ZipSlip {
|
||||
path: name.to_owned(),
|
||||
})?;
|
||||
if !is_safe_relative_path(&safe) {
|
||||
return Err(ImportError::ZipSlip {
|
||||
path: name.to_owned(),
|
||||
});
|
||||
}
|
||||
let dest = staging.join(&safe);
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut out = File::create(&dest)?;
|
||||
io::copy(&mut entry, &mut out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Variant of [`write_archive_entry`] keyed by `Path` for the
|
||||
/// manifest-declared face/back paths.
|
||||
fn write_archive_entry_pathbuf<R: io::Read + io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
path: &Path,
|
||||
staging: &Path,
|
||||
) -> Result<(), ImportError> {
|
||||
let key = archive_key(path);
|
||||
write_archive_entry(archive, &key, staging)
|
||||
}
|
||||
|
||||
/// Promotes the staging directory to its final location.
|
||||
///
|
||||
/// `fs::rename` is atomic when both paths share a filesystem; if not
|
||||
/// (e.g. `/tmp` on a tmpfs vs. the user data dir on disk) we fall
|
||||
/// back to a recursive copy + remove so the import still completes.
|
||||
fn install_atomic(staging: &Path, final_dir: &Path) -> Result<(), ImportError> {
|
||||
match fs::rename(staging, final_dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(rename_err) => {
|
||||
// EXDEV (cross-device link) is the canonical case for
|
||||
// falling back; other rename errors are surfaced after
|
||||
// the fallback so the user sees the original cause if
|
||||
// copy also fails.
|
||||
copy_dir_recursive(staging, final_dir).map_err(|copy_err| {
|
||||
ImportError::Io(io::Error::new(
|
||||
copy_err.kind(),
|
||||
format!(
|
||||
"cross-device install fallback failed: rename={rename_err}, copy={copy_err}"
|
||||
),
|
||||
))
|
||||
})?;
|
||||
// Best-effort: if removing the staging dir fails the
|
||||
// import is still a success — the user has the theme.
|
||||
let _ = fs::remove_dir_all(staging);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain recursive copy used by [`install_atomic`] when `rename`
|
||||
/// can't cross filesystem boundaries.
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
fs::create_dir_all(dst)?;
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let from = entry.path();
|
||||
let to = dst.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_dir_recursive(&from, &to)?;
|
||||
} else {
|
||||
fs::copy(&from, &to)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::CompressionMethod;
|
||||
|
||||
use crate::theme::manifest::ThemeManifest;
|
||||
use crate::theme::{CardKey, ThemeMeta};
|
||||
|
||||
/// Smallest non-trivial SVG that round-trips through `usvg` /
|
||||
/// `resvg`. Matches the fixture used by `svg_loader::tests`.
|
||||
const TEST_SVG: &[u8] = br##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
|
||||
<rect x="0" y="0" width="200" height="300" fill="#FFD23F"/>
|
||||
<circle cx="100" cy="150" r="80" fill="#1A0F2E"/>
|
||||
</svg>"##;
|
||||
|
||||
fn meta(id: &str) -> ThemeMeta {
|
||||
ThemeMeta {
|
||||
id: id.to_owned(),
|
||||
name: "Test Theme".into(),
|
||||
author: "Tester".into(),
|
||||
version: "1.0.0".into(),
|
||||
card_aspect: (2, 3),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a manifest that maps every card and the back to a
|
||||
/// per-card SVG path inside the archive root.
|
||||
fn full_manifest(id: &str) -> ThemeManifest {
|
||||
let faces: HashMap<String, PathBuf> = CardKey::all()
|
||||
.map(|k| {
|
||||
let name = k.manifest_name();
|
||||
(name.clone(), PathBuf::from(format!("faces/{name}.svg")))
|
||||
})
|
||||
.collect();
|
||||
ThemeManifest {
|
||||
meta: meta(id),
|
||||
back: PathBuf::from("back.svg"),
|
||||
faces,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a zip archive at `zip_path` containing every entry in
|
||||
/// `entries` (path → bytes). Uses `Stored` so tests don't pull
|
||||
/// the deflate codepath into the assertion semantics.
|
||||
fn write_zip(zip_path: &Path, entries: &[(&str, Vec<u8>)]) {
|
||||
let file = File::create(zip_path).expect("create zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let opts: SimpleFileOptions =
|
||||
SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
for (name, bytes) in entries {
|
||||
writer.start_file(*name, opts).expect("start file");
|
||||
writer.write_all(bytes).expect("write entry");
|
||||
}
|
||||
writer.finish().expect("finish zip");
|
||||
}
|
||||
|
||||
/// Builds a complete, valid theme zip at `zip_path` with the
|
||||
/// given manifest id.
|
||||
fn write_valid_zip(zip_path: &Path, id: &str) {
|
||||
let manifest = full_manifest(id);
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::with_capacity(54);
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
|
||||
for k in CardKey::all() {
|
||||
entries.push((
|
||||
format!("faces/{}.svg", k.manifest_name()),
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
write_zip(zip_path, &entries_ref);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_zip_imports_and_extracts_files() {
|
||||
let scratch = TempDir::new().expect("scratch");
|
||||
let zip_path = scratch.path().join("good.zip");
|
||||
write_valid_zip(&zip_path, "fancy");
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let id = import_theme_into(&zip_path, target.path()).expect("import succeeds");
|
||||
|
||||
assert_eq!(id.as_str(), "fancy");
|
||||
let installed = target.path().join("fancy");
|
||||
assert!(installed.is_dir(), "theme dir should exist");
|
||||
assert!(installed.join("theme.ron").is_file(), "manifest extracted");
|
||||
assert!(installed.join("back.svg").is_file(), "back extracted");
|
||||
assert!(
|
||||
installed.join("faces/hearts_ace.svg").is_file(),
|
||||
"face extracted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_manifest_is_rejected() {
|
||||
let scratch = TempDir::new().expect("scratch");
|
||||
let zip_path = scratch.path().join("no_manifest.zip");
|
||||
// No `theme.ron` at root, but plenty of other content.
|
||||
write_zip(
|
||||
&zip_path,
|
||||
&[
|
||||
("back.svg", TEST_SVG.to_vec()),
|
||||
("faces/hearts_ace.svg", TEST_SVG.to_vec()),
|
||||
],
|
||||
);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
assert!(
|
||||
matches!(err, ImportError::MissingManifest),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(
|
||||
target.path().read_dir().unwrap().next().is_none(),
|
||||
"target untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_with_only_51_faces_is_rejected() {
|
||||
let scratch = TempDir::new().expect("scratch");
|
||||
let zip_path = scratch.path().join("missing_face.zip");
|
||||
|
||||
let mut manifest = full_manifest("incomplete");
|
||||
// Drop one face so validation surfaces MissingFaces.
|
||||
manifest.faces.remove("hearts_ace");
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
|
||||
for k in CardKey::all() {
|
||||
entries.push((
|
||||
format!("faces/{}.svg", k.manifest_name()),
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
write_zip(&zip_path, &entries_ref);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
match err {
|
||||
ImportError::Validation(ManifestError::MissingFaces { missing }) => {
|
||||
assert!(missing.iter().any(|s| s == "hearts_ace"));
|
||||
}
|
||||
other => panic!("expected MissingFaces, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
target.path().read_dir().unwrap().next().is_none(),
|
||||
"target untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_archive_is_rejected() {
|
||||
let scratch = TempDir::new().expect("scratch");
|
||||
let zip_path = scratch.path().join("huge.zip");
|
||||
|
||||
// Compose a single entry whose declared uncompressed size
|
||||
// exceeds the cap. We use a real Vec here (compressed via
|
||||
// Stored) because zip's central directory mirrors the
|
||||
// payload size we actually wrote.
|
||||
let huge = vec![0u8; (MAX_ARCHIVE_BYTES + 1) as usize];
|
||||
write_zip(
|
||||
&zip_path,
|
||||
&[
|
||||
(MANIFEST_NAME, b"".to_vec()),
|
||||
("filler.bin", huge),
|
||||
],
|
||||
);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
assert!(
|
||||
matches!(err, ImportError::Oversized { .. }),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(
|
||||
target.path().read_dir().unwrap().next().is_none(),
|
||||
"target untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_slip_path_is_rejected() {
|
||||
let scratch = TempDir::new().expect("scratch");
|
||||
let zip_path = scratch.path().join("slip.zip");
|
||||
|
||||
// Build the zip with a deliberately path-traversing entry
|
||||
// name. We bypass `write_zip`'s normal flow because the
|
||||
// SimpleFileOptions API will gladly accept any string.
|
||||
let file = File::create(&zip_path).expect("create zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let opts: SimpleFileOptions =
|
||||
SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
writer
|
||||
.start_file("../etc/passwd", opts)
|
||||
.expect("start file");
|
||||
writer.write_all(b"not really").expect("write entry");
|
||||
writer.finish().expect("finish zip");
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
assert!(matches!(err, ImportError::ZipSlip { .. }), "got {err:?}");
|
||||
assert!(
|
||||
target.path().read_dir().unwrap().next().is_none(),
|
||||
"target untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_referenced_face_missing_from_archive_is_rejected() {
|
||||
let scratch = TempDir::new().expect("scratch");
|
||||
let zip_path = scratch.path().join("missing_file.zip");
|
||||
|
||||
// Manifest is well-formed and validates, but we omit one of
|
||||
// the SVGs from the archive to trigger the MissingFile path.
|
||||
let manifest = full_manifest("missing_file_theme");
|
||||
let manifest_ron = ron::ser::to_string_pretty(
|
||||
&manifest,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("ron serialise");
|
||||
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
entries.push((MANIFEST_NAME.to_owned(), manifest_ron.into_bytes()));
|
||||
entries.push(("back.svg".to_owned(), TEST_SVG.to_vec()));
|
||||
for k in CardKey::all() {
|
||||
// Skip hearts_ace.svg so its manifest entry has no
|
||||
// matching archive payload.
|
||||
if k.manifest_name() == "hearts_ace" {
|
||||
continue;
|
||||
}
|
||||
entries.push((
|
||||
format!("faces/{}.svg", k.manifest_name()),
|
||||
TEST_SVG.to_vec(),
|
||||
));
|
||||
}
|
||||
let entries_ref: Vec<(&str, Vec<u8>)> =
|
||||
entries.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
|
||||
write_zip(&zip_path, &entries_ref);
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
match err {
|
||||
ImportError::MissingFile { missing } => {
|
||||
assert!(
|
||||
missing.contains("hearts_ace"),
|
||||
"missing path should mention hearts_ace, got {missing}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected MissingFile, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
!target.path().join("missing_file_theme").exists(),
|
||||
"target untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_collision_with_existing_dir_is_rejected() {
|
||||
let scratch = TempDir::new().expect("scratch");
|
||||
let zip_path = scratch.path().join("collide.zip");
|
||||
write_valid_zip(&zip_path, "duplicate");
|
||||
|
||||
let target = TempDir::new().expect("target");
|
||||
// Pre-populate the destination so the import path runs
|
||||
// straight into the IdCollision check.
|
||||
fs::create_dir_all(target.path().join("duplicate")).unwrap();
|
||||
|
||||
let err = import_theme_into(&zip_path, target.path()).expect_err("expected error");
|
||||
match err {
|
||||
ImportError::IdCollision { id } => assert_eq!(id, "duplicate"),
|
||||
other => panic!("expected IdCollision, got {other:?}"),
|
||||
}
|
||||
// Existing dir must still be there, but no extracted files
|
||||
// should have been copied into it.
|
||||
let existing = target.path().join("duplicate");
|
||||
assert!(existing.is_dir());
|
||||
assert!(!existing.join("theme.ron").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! `AssetLoader` for `.theme.ron` manifests.
|
||||
//!
|
||||
//! Reads the manifest, validates structurally (52 faces, sane meta),
|
||||
//! then schedules each referenced SVG via [`crate::assets::SvgLoader`]
|
||||
//! at the resolution implied by `meta.card_aspect`. The resulting
|
||||
//! `Handle<Image>`s are stored on the [`super::CardTheme`] asset, so
|
||||
//! Bevy's asset dependency graph keeps each face alive for as long as
|
||||
//! the theme is alive.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::asset::io::Reader;
|
||||
use bevy::asset::{AssetLoader, AssetPath, LoadContext, ParseAssetPathError};
|
||||
use bevy::reflect::TypePath;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::assets::SvgLoaderSettings;
|
||||
|
||||
use super::manifest::{ManifestError, ThemeManifest};
|
||||
use super::{CardKey, CardTheme};
|
||||
|
||||
/// Default rasterisation height when the manifest's `card_aspect`
|
||||
/// implies a 2:3 card. 768 px tall × 512 px wide stays sharp on
|
||||
/// any reasonable desktop window. Mobile viewports may want larger;
|
||||
/// the per-load settings hook in `SvgLoader` stays available for
|
||||
/// future overrides.
|
||||
const DEFAULT_CARD_HEIGHT_PX: u32 = 768;
|
||||
|
||||
/// Errors raised by [`CardThemeLoader::load`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CardThemeLoaderError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("manifest parse (RON): {0}")]
|
||||
Parse(#[from] ron::error::SpannedError),
|
||||
#[error("manifest validation: {0}")]
|
||||
Validation(#[from] ManifestError),
|
||||
/// `AssetPath::resolve` rejected a manifest-relative path. Almost
|
||||
/// always means the manifest contains an absolute path or a
|
||||
/// surface that includes a custom asset source the manifest
|
||||
/// shouldn't be reaching across.
|
||||
#[error("could not resolve asset path: {0}")]
|
||||
PathResolve(#[from] ParseAssetPathError),
|
||||
}
|
||||
|
||||
/// `AssetLoader` registered for the `.theme.ron` extension.
|
||||
#[derive(Debug, Default, TypePath)]
|
||||
pub struct CardThemeLoader;
|
||||
|
||||
impl AssetLoader for CardThemeLoader {
|
||||
type Asset = CardTheme;
|
||||
type Settings = ();
|
||||
type Error = CardThemeLoaderError;
|
||||
|
||||
async fn load(
|
||||
&self,
|
||||
reader: &mut dyn Reader,
|
||||
_settings: &Self::Settings,
|
||||
load_context: &mut LoadContext<'_>,
|
||||
) -> Result<CardTheme, Self::Error> {
|
||||
let mut bytes = Vec::new();
|
||||
reader.read_to_end(&mut bytes).await?;
|
||||
let manifest: ThemeManifest = ron::de::from_bytes(&bytes)?;
|
||||
|
||||
// Surfaces metadata + face-completeness errors with named
|
||||
// diagnostics before we touch the asset graph.
|
||||
let face_paths = manifest.validate()?;
|
||||
let target = target_size_from_aspect(manifest.meta.card_aspect);
|
||||
|
||||
// Clone the manifest's own asset path so we can compose
|
||||
// sibling paths via `AssetPath::resolve` without holding an
|
||||
// immutable borrow of `load_context` while we mutably borrow
|
||||
// it via `.loader()`.
|
||||
let manifest_path: AssetPath<'static> = load_context.path().clone();
|
||||
|
||||
let back_path = manifest_path.resolve(&path_to_str(&manifest.back))?;
|
||||
let face_full: Vec<(CardKey, AssetPath<'static>)> = face_paths
|
||||
.iter()
|
||||
.map(|(k, p)| {
|
||||
manifest_path
|
||||
.resolve(&path_to_str(p))
|
||||
.map(|ap| (*k, ap))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let mut faces = HashMap::with_capacity(face_full.len());
|
||||
for (key, full_path) in face_full {
|
||||
let handle = load_context
|
||||
.loader()
|
||||
.with_settings(move |s: &mut SvgLoaderSettings| s.target_size = target)
|
||||
.load(full_path);
|
||||
faces.insert(key, handle);
|
||||
}
|
||||
let back = load_context
|
||||
.loader()
|
||||
.with_settings(move |s: &mut SvgLoaderSettings| s.target_size = target)
|
||||
.load(back_path);
|
||||
|
||||
Ok(CardTheme {
|
||||
meta: manifest.meta,
|
||||
faces,
|
||||
back,
|
||||
})
|
||||
}
|
||||
|
||||
fn extensions(&self) -> &[&str] {
|
||||
&["theme.ron"]
|
||||
}
|
||||
}
|
||||
|
||||
/// `AssetPath::resolve` takes `&str`; manifest paths are `PathBuf`.
|
||||
/// Lossy is acceptable here because manifest paths must be plain ASCII
|
||||
/// for cross-platform asset resolution to behave consistently.
|
||||
fn path_to_str(p: &std::path::Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Translates `card_aspect` into the SVG rasteriser's target pixel
|
||||
/// size. Height is held constant at [`DEFAULT_CARD_HEIGHT_PX`]; width
|
||||
/// is derived to preserve the aspect, with a minimum of 1 px so a
|
||||
/// degenerate-but-validated aspect doesn't produce a 0-width pixmap.
|
||||
fn target_size_from_aspect(aspect: (u32, u32)) -> bevy::math::UVec2 {
|
||||
let (num, denom) = aspect;
|
||||
let width = ((DEFAULT_CARD_HEIGHT_PX as u64 * num as u64) / denom as u64).max(1) as u32;
|
||||
bevy::math::UVec2::new(width, DEFAULT_CARD_HEIGHT_PX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_size_2_to_3_yields_512_by_768() {
|
||||
assert_eq!(
|
||||
target_size_from_aspect((2, 3)),
|
||||
bevy::math::UVec2::new(512, 768)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_size_handles_non_standard_aspect() {
|
||||
// 3:4 → wider card.
|
||||
let v = target_size_from_aspect((3, 4));
|
||||
assert_eq!(v.y, DEFAULT_CARD_HEIGHT_PX);
|
||||
assert_eq!(v.x, 576);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_size_clamps_to_at_least_1px_wide() {
|
||||
// 1:10000 would otherwise round to zero.
|
||||
let v = target_size_from_aspect((1, 10_000));
|
||||
assert!(v.x >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_advertises_theme_ron_extension() {
|
||||
let loader = CardThemeLoader;
|
||||
assert_eq!(loader.extensions(), &["theme.ron"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! On-disk theme manifest schema (`.theme.ron`).
|
||||
//!
|
||||
//! A manifest is a single RON file that lists, for one card theme, the
|
||||
//! display metadata plus the 52 face SVG paths and one back SVG path.
|
||||
//! Paths are interpreted relative to the manifest file's directory so
|
||||
//! the same manifest works whether the theme is bundled via
|
||||
//! `embedded://`, dropped under `themes://`, or unpacked into a temp
|
||||
//! dir during import validation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::{CardKey, ThemeMeta, ThemeMetaError};
|
||||
|
||||
/// Raw deserialised manifest. Keys in `faces` use the canonical
|
||||
/// [`CardKey::manifest_name`] string form (e.g. `"hearts_ace"`); the
|
||||
/// loader converts to `HashMap<CardKey, _>` after validating that all
|
||||
/// 52 entries are present.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ThemeManifest {
|
||||
pub meta: ThemeMeta,
|
||||
pub back: PathBuf,
|
||||
pub faces: HashMap<String, PathBuf>,
|
||||
}
|
||||
|
||||
/// Errors raised by [`ThemeManifest::validate`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("theme metadata invalid: {0}")]
|
||||
Meta(#[from] ThemeMetaError),
|
||||
#[error("manifest face key {key:?} is not a valid card name")]
|
||||
UnknownFaceKey { key: String },
|
||||
#[error("manifest is missing face entries: {missing:?}")]
|
||||
MissingFaces { missing: Vec<String> },
|
||||
#[error("manifest declares {duplicate} twice with different paths")]
|
||||
DuplicateFace { duplicate: String },
|
||||
}
|
||||
|
||||
impl ThemeManifest {
|
||||
/// Parses the manifest's face map into a strongly-typed
|
||||
/// `HashMap<CardKey, PathBuf>`, surfacing precise errors for
|
||||
/// (a) keys that don't name a real card, (b) any of the 52 cards
|
||||
/// that the manifest forgot to list, and (c) duplicate keys (RON
|
||||
/// silently keeps the last value, which is brittle behaviour for
|
||||
/// a release). Also runs [`ThemeMeta::validate`] up front so
|
||||
/// metadata-level errors surface before path validation.
|
||||
pub fn validate(&self) -> Result<HashMap<CardKey, PathBuf>, ManifestError> {
|
||||
self.meta.validate()?;
|
||||
|
||||
let mut faces: HashMap<CardKey, PathBuf> = HashMap::with_capacity(52);
|
||||
for (key_str, path) in &self.faces {
|
||||
let key = CardKey::parse_manifest_name(key_str).ok_or_else(|| {
|
||||
ManifestError::UnknownFaceKey {
|
||||
key: key_str.clone(),
|
||||
}
|
||||
})?;
|
||||
if faces.insert(key, path.clone()).is_some() {
|
||||
return Err(ManifestError::DuplicateFace {
|
||||
duplicate: key_str.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let missing: Vec<String> = CardKey::all()
|
||||
.filter(|k| !faces.contains_key(k))
|
||||
.map(CardKey::manifest_name)
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(ManifestError::MissingFaces { missing });
|
||||
}
|
||||
|
||||
Ok(faces)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta() -> ThemeMeta {
|
||||
ThemeMeta {
|
||||
id: "default".into(),
|
||||
name: "Default".into(),
|
||||
author: "Solitaire Quest".into(),
|
||||
version: "1.0.0".into(),
|
||||
card_aspect: (2, 3),
|
||||
}
|
||||
}
|
||||
|
||||
fn full_face_map() -> HashMap<String, PathBuf> {
|
||||
CardKey::all()
|
||||
.map(|k| (k.manifest_name(), PathBuf::from(format!("{}.svg", k.manifest_name()))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_manifest_validates() {
|
||||
let m = ThemeManifest {
|
||||
meta: meta(),
|
||||
back: PathBuf::from("back.svg"),
|
||||
faces: full_face_map(),
|
||||
};
|
||||
let parsed = m.validate().expect("valid manifest");
|
||||
assert_eq!(parsed.len(), 52);
|
||||
for k in CardKey::all() {
|
||||
assert!(parsed.contains_key(&k), "{} missing", k.manifest_name());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_face_is_rejected_with_a_named_list() {
|
||||
let mut faces = full_face_map();
|
||||
faces.remove("hearts_ace");
|
||||
faces.remove("spades_king");
|
||||
|
||||
let m = ThemeManifest {
|
||||
meta: meta(),
|
||||
back: PathBuf::from("back.svg"),
|
||||
faces,
|
||||
};
|
||||
|
||||
match m.validate() {
|
||||
Err(ManifestError::MissingFaces { missing }) => {
|
||||
assert!(missing.iter().any(|s| s == "hearts_ace"));
|
||||
assert!(missing.iter().any(|s| s == "spades_king"));
|
||||
}
|
||||
other => panic!("expected MissingFaces, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_face_key_is_rejected() {
|
||||
let mut faces = full_face_map();
|
||||
faces.insert("not_a_card".into(), PathBuf::from("nope.svg"));
|
||||
|
||||
let m = ThemeManifest {
|
||||
meta: meta(),
|
||||
back: PathBuf::from("back.svg"),
|
||||
faces,
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
m.validate(),
|
||||
Err(ManifestError::UnknownFaceKey { key }) if key == "not_a_card"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_meta_propagates() {
|
||||
let mut bad_meta = meta();
|
||||
bad_meta.id = "../escape".into();
|
||||
let m = ThemeManifest {
|
||||
meta: bad_meta,
|
||||
back: PathBuf::from("back.svg"),
|
||||
faces: full_face_map(),
|
||||
};
|
||||
assert!(matches!(m.validate(), Err(ManifestError::Meta(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ron_round_trip_preserves_manifest() {
|
||||
let m = ThemeManifest {
|
||||
meta: meta(),
|
||||
back: PathBuf::from("back.svg"),
|
||||
faces: full_face_map(),
|
||||
};
|
||||
let serialised = ron::ser::to_string_pretty(
|
||||
&m,
|
||||
ron::ser::PrettyConfig::default(),
|
||||
)
|
||||
.expect("serde_ron");
|
||||
let parsed: ThemeManifest = ron::from_str(&serialised).expect("ron parse");
|
||||
assert_eq!(parsed.meta, m.meta);
|
||||
assert_eq!(parsed.back, m.back);
|
||||
assert_eq!(parsed.faces, m.faces);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Card-theme asset type.
|
||||
//!
|
||||
//! A `CardTheme` is a self-contained set of 52 face images plus one back
|
||||
//! image, addressable by `CardKey`. Themes are loaded from RON manifests
|
||||
//! (`.theme.ron`) by [`CardThemeLoader`]; the loader rasterises every
|
||||
//! referenced SVG via [`crate::assets::SvgLoader`] and binds the
|
||||
//! resulting `Handle<Image>` to its `CardKey`.
|
||||
//!
|
||||
//! The runtime card-rendering systems read the active theme through
|
||||
//! [`crate::theme::ActiveTheme`] (added in Phase 4) and look up
|
||||
//! `theme.faces.get(&card_key)` per render. They never store image
|
||||
//! handles directly on card entities, so a theme switch propagates on
|
||||
//! the next frame without re-spawning anything.
|
||||
|
||||
pub mod importer;
|
||||
pub mod loader;
|
||||
pub mod manifest;
|
||||
pub mod plugin;
|
||||
pub mod registry;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::asset::{Asset, Handle};
|
||||
use bevy::image::Image;
|
||||
use bevy::reflect::TypePath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
|
||||
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
|
||||
pub use loader::{CardThemeLoader, CardThemeLoaderError};
|
||||
pub use manifest::ThemeManifest;
|
||||
pub use plugin::{set_theme, ActiveTheme, ThemePlugin};
|
||||
pub use registry::{
|
||||
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
|
||||
};
|
||||
|
||||
/// Hashable lookup key into [`CardTheme::faces`].
|
||||
///
|
||||
/// Distinct from `solitaire_core::Card`: the core type carries an `id`
|
||||
/// and a `face_up` flag that vary per deal, neither of which is
|
||||
/// relevant to image lookup. `CardKey` is just the (suit, rank) pair
|
||||
/// that uniquely identifies which artwork to draw.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct CardKey {
|
||||
pub suit: Suit,
|
||||
pub rank: Rank,
|
||||
}
|
||||
|
||||
impl CardKey {
|
||||
/// Constructs a key from a `(suit, rank)` pair.
|
||||
pub const fn new(suit: Suit, rank: Rank) -> Self {
|
||||
Self { suit, rank }
|
||||
}
|
||||
|
||||
/// Iterator over all 52 valid keys, in suit-major / rank-ascending order.
|
||||
/// Used to enumerate the manifest's required entries.
|
||||
pub fn all() -> impl Iterator<Item = CardKey> {
|
||||
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
const RANKS: [Rank; 13] = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen,
|
||||
Rank::King,
|
||||
];
|
||||
SUITS
|
||||
.into_iter()
|
||||
.flat_map(|s| RANKS.into_iter().map(move |r| CardKey::new(s, r)))
|
||||
}
|
||||
|
||||
/// Canonical manifest-key string: `"{suit}_{rank}"` lowercase.
|
||||
/// e.g. `"hearts_ace"`, `"spades_10"`, `"clubs_king"`.
|
||||
pub fn manifest_name(self) -> String {
|
||||
format!("{}_{}", suit_token(self.suit), rank_token(self.rank))
|
||||
}
|
||||
|
||||
/// Inverse of [`CardKey::manifest_name`]. Accepts the canonical
|
||||
/// `"{suit}_{rank}"` form. Returns `None` for any other shape so
|
||||
/// the manifest loader surfaces a clear error message instead of
|
||||
/// silently picking wrong defaults.
|
||||
pub fn parse_manifest_name(s: &str) -> Option<CardKey> {
|
||||
let (suit_part, rank_part) = s.split_once('_')?;
|
||||
Some(CardKey::new(parse_suit(suit_part)?, parse_rank(rank_part)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn suit_token(s: Suit) -> &'static str {
|
||||
match s {
|
||||
Suit::Clubs => "clubs",
|
||||
Suit::Diamonds => "diamonds",
|
||||
Suit::Hearts => "hearts",
|
||||
Suit::Spades => "spades",
|
||||
}
|
||||
}
|
||||
|
||||
fn rank_token(r: Rank) -> &'static str {
|
||||
match r {
|
||||
Rank::Ace => "ace",
|
||||
Rank::Two => "2",
|
||||
Rank::Three => "3",
|
||||
Rank::Four => "4",
|
||||
Rank::Five => "5",
|
||||
Rank::Six => "6",
|
||||
Rank::Seven => "7",
|
||||
Rank::Eight => "8",
|
||||
Rank::Nine => "9",
|
||||
Rank::Ten => "10",
|
||||
Rank::Jack => "jack",
|
||||
Rank::Queen => "queen",
|
||||
Rank::King => "king",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_suit(s: &str) -> Option<Suit> {
|
||||
match s {
|
||||
"clubs" => Some(Suit::Clubs),
|
||||
"diamonds" => Some(Suit::Diamonds),
|
||||
"hearts" => Some(Suit::Hearts),
|
||||
"spades" => Some(Suit::Spades),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rank(s: &str) -> Option<Rank> {
|
||||
match s {
|
||||
"ace" => Some(Rank::Ace),
|
||||
"2" => Some(Rank::Two),
|
||||
"3" => Some(Rank::Three),
|
||||
"4" => Some(Rank::Four),
|
||||
"5" => Some(Rank::Five),
|
||||
"6" => Some(Rank::Six),
|
||||
"7" => Some(Rank::Seven),
|
||||
"8" => Some(Rank::Eight),
|
||||
"9" => Some(Rank::Nine),
|
||||
"10" => Some(Rank::Ten),
|
||||
"jack" => Some(Rank::Jack),
|
||||
"queen" => Some(Rank::Queen),
|
||||
"king" => Some(Rank::King),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-facing metadata stored in every theme manifest. Surfaces in
|
||||
/// the future picker UI (Phase 6) and is preserved on disk so the
|
||||
/// importer (Phase 7) can validate that two themes don't collide on
|
||||
/// `id`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ThemeMeta {
|
||||
/// Unique opaque identifier — also the directory name on disk.
|
||||
/// Must be filesystem-safe (no path separators); the importer
|
||||
/// enforces this.
|
||||
pub id: String,
|
||||
/// Display name shown in the picker.
|
||||
pub name: String,
|
||||
/// Author attribution (free-form text).
|
||||
pub author: String,
|
||||
/// Version string (free-form, but conventionally semver).
|
||||
pub version: String,
|
||||
/// Card aspect ratio as `(numerator, denominator)`. The SVG
|
||||
/// rasteriser uses this to choose a target size that preserves
|
||||
/// the artwork's intended proportions when the player resizes the
|
||||
/// window. Standard playing cards are 2:3.
|
||||
pub card_aspect: (u32, u32),
|
||||
}
|
||||
|
||||
/// Errors surfaced by [`ThemeMeta::validate`].
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ThemeMetaError {
|
||||
#[error("theme id is empty")]
|
||||
EmptyId,
|
||||
#[error("theme id contains a path separator: {0:?}")]
|
||||
PathSeparatorInId(String),
|
||||
#[error("card_aspect denominator is zero")]
|
||||
ZeroDenominator,
|
||||
#[error("card_aspect numerator is zero")]
|
||||
ZeroNumerator,
|
||||
}
|
||||
|
||||
impl ThemeMeta {
|
||||
/// Validates surface invariants. The importer (Phase 7) calls this
|
||||
/// before unpacking a zip into the user-themes directory so it
|
||||
/// can reject ill-formed manifests early without filesystem side
|
||||
/// effects.
|
||||
pub fn validate(&self) -> Result<(), ThemeMetaError> {
|
||||
if self.id.is_empty() {
|
||||
return Err(ThemeMetaError::EmptyId);
|
||||
}
|
||||
if self.id.contains('/') || self.id.contains('\\') {
|
||||
return Err(ThemeMetaError::PathSeparatorInId(self.id.clone()));
|
||||
}
|
||||
if self.card_aspect.0 == 0 {
|
||||
return Err(ThemeMetaError::ZeroNumerator);
|
||||
}
|
||||
if self.card_aspect.1 == 0 {
|
||||
return Err(ThemeMetaError::ZeroDenominator);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded card theme — 52 face images + 1 back image + metadata.
|
||||
///
|
||||
/// `faces` is keyed by [`CardKey`]; every key produced by
|
||||
/// `CardKey::all()` is guaranteed to be present (the loader rejects
|
||||
/// manifests that miss any of the 52 entries).
|
||||
#[derive(Asset, TypePath, Debug)]
|
||||
pub struct CardTheme {
|
||||
pub meta: ThemeMeta,
|
||||
pub faces: HashMap<CardKey, Handle<Image>>,
|
||||
pub back: Handle<Image>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_yields_52_unique_keys() {
|
||||
let keys: Vec<CardKey> = CardKey::all().collect();
|
||||
assert_eq!(keys.len(), 52);
|
||||
let unique: std::collections::HashSet<CardKey> = keys.iter().copied().collect();
|
||||
assert_eq!(unique.len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_name_round_trips_for_every_card() {
|
||||
for key in CardKey::all() {
|
||||
let name = key.manifest_name();
|
||||
assert_eq!(
|
||||
CardKey::parse_manifest_name(&name),
|
||||
Some(key),
|
||||
"round-trip failed for {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_name_examples() {
|
||||
assert_eq!(
|
||||
CardKey::new(Suit::Hearts, Rank::Ace).manifest_name(),
|
||||
"hearts_ace"
|
||||
);
|
||||
assert_eq!(
|
||||
CardKey::new(Suit::Spades, Rank::Ten).manifest_name(),
|
||||
"spades_10"
|
||||
);
|
||||
assert_eq!(
|
||||
CardKey::new(Suit::Clubs, Rank::King).manifest_name(),
|
||||
"clubs_king"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_name_rejects_garbage() {
|
||||
assert!(CardKey::parse_manifest_name("nope").is_none());
|
||||
assert!(CardKey::parse_manifest_name("hearts").is_none());
|
||||
assert!(CardKey::parse_manifest_name("hearts_").is_none());
|
||||
assert!(CardKey::parse_manifest_name("_ace").is_none());
|
||||
assert!(CardKey::parse_manifest_name("hearts_15").is_none());
|
||||
assert!(CardKey::parse_manifest_name("HEARTS_ACE").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_meta_validates_well_formed() {
|
||||
let meta = ThemeMeta {
|
||||
id: "default".into(),
|
||||
name: "Default".into(),
|
||||
author: "Solitaire Quest".into(),
|
||||
version: "1.0.0".into(),
|
||||
card_aspect: (2, 3),
|
||||
};
|
||||
assert_eq!(meta.validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_meta_rejects_empty_id() {
|
||||
let meta = ThemeMeta {
|
||||
id: String::new(),
|
||||
name: "x".into(),
|
||||
author: "x".into(),
|
||||
version: "x".into(),
|
||||
card_aspect: (2, 3),
|
||||
};
|
||||
assert_eq!(meta.validate(), Err(ThemeMetaError::EmptyId));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_meta_rejects_path_separator_in_id() {
|
||||
let meta = ThemeMeta {
|
||||
id: "../etc/passwd".into(),
|
||||
name: "x".into(),
|
||||
author: "x".into(),
|
||||
version: "x".into(),
|
||||
card_aspect: (2, 3),
|
||||
};
|
||||
assert!(matches!(
|
||||
meta.validate(),
|
||||
Err(ThemeMetaError::PathSeparatorInId(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_meta_rejects_zero_aspect_components() {
|
||||
let mut meta = ThemeMeta {
|
||||
id: "x".into(),
|
||||
name: "x".into(),
|
||||
author: "x".into(),
|
||||
version: "x".into(),
|
||||
card_aspect: (0, 3),
|
||||
};
|
||||
assert_eq!(meta.validate(), Err(ThemeMetaError::ZeroNumerator));
|
||||
meta.card_aspect = (2, 0);
|
||||
assert_eq!(meta.validate(), Err(ThemeMetaError::ZeroDenominator));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! `ThemePlugin` — owns [`ActiveTheme`], registers the `CardTheme` /
|
||||
//! SVG asset machinery, and keeps `card_plugin::CardImageSet` in sync
|
||||
//! with the currently-loaded theme so existing card-rendering systems
|
||||
//! pick up the new artwork on the next state-changed tick.
|
||||
//!
|
||||
//! Phase 4 of `CARD_PLAN.md`. The plugin's `set_theme` helper is the
|
||||
//! public API that the future picker UI (Phase 6) calls; for now it's
|
||||
//! exposed for tests and for any embedder that wants to load an
|
||||
//! alternative theme manually.
|
||||
|
||||
use bevy::asset::AssetEvent;
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
|
||||
use crate::assets::DEFAULT_THEME_MANIFEST_URL;
|
||||
use crate::card_plugin::CardImageSet;
|
||||
use crate::events::StateChangedEvent;
|
||||
|
||||
use super::loader::CardThemeLoader;
|
||||
use super::{CardKey, CardTheme};
|
||||
|
||||
/// Resource pointing at the currently-active card theme. Populated on
|
||||
/// startup with the bundled default theme and replaced by [`set_theme`]
|
||||
/// when the player switches.
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct ActiveTheme(pub Handle<CardTheme>);
|
||||
|
||||
/// Bevy plugin that loads the default theme and keeps `CardImageSet`
|
||||
/// in sync with `Assets<CardTheme>`.
|
||||
///
|
||||
/// Order considerations:
|
||||
///
|
||||
/// - `init_asset::<CardTheme>` must happen before any system that
|
||||
/// stores `Handle<CardTheme>` runs, so it goes in `build`.
|
||||
/// - `register_asset_loader` for the SVG and theme loaders must
|
||||
/// happen after `AssetPlugin` is built (DefaultPlugins). This
|
||||
/// plugin therefore must be added after `DefaultPlugins`.
|
||||
/// - The `Startup` system that loads the default theme runs after
|
||||
/// the asset sources are registered (see
|
||||
/// `crate::assets::register_theme_asset_sources` and
|
||||
/// `crate::assets::AssetSourcesPlugin`).
|
||||
pub struct ThemePlugin;
|
||||
|
||||
impl Plugin for ThemePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_asset::<CardTheme>()
|
||||
.register_asset_loader(crate::assets::SvgLoader)
|
||||
.register_asset_loader(CardThemeLoader)
|
||||
.add_systems(Startup, load_default_theme)
|
||||
.add_systems(Update, sync_card_image_set_with_active_theme);
|
||||
}
|
||||
}
|
||||
|
||||
/// Kicks off the default-theme load and stashes the handle on
|
||||
/// [`ActiveTheme`]. The actual rasterisation runs asynchronously on
|
||||
/// the asset task pool; the sync system below picks up the
|
||||
/// `LoadedWithDependencies` event when every face + back is ready.
|
||||
fn load_default_theme(asset_server: Res<AssetServer>, mut commands: Commands) {
|
||||
let handle: Handle<CardTheme> = asset_server.load(DEFAULT_THEME_MANIFEST_URL);
|
||||
commands.insert_resource(ActiveTheme(handle));
|
||||
}
|
||||
|
||||
/// Replaces every face slot and slot 0 of the back array on
|
||||
/// `CardImageSet` whenever the active theme finishes loading or
|
||||
/// changes. Fires `StateChangedEvent` afterwards so the existing
|
||||
/// `card_plugin::sync_cards_on_change` pipeline re-renders every
|
||||
/// on-screen card with the new artwork.
|
||||
///
|
||||
/// `CardImageSet` may be absent — tests using `MinimalPlugins` skip
|
||||
/// `CardPlugin` entirely. In that case the system is a no-op and the
|
||||
/// plugin still composes cleanly under headless setups.
|
||||
fn sync_card_image_set_with_active_theme(
|
||||
mut events: MessageReader<AssetEvent<CardTheme>>,
|
||||
active: Option<Res<ActiveTheme>>,
|
||||
themes: Res<Assets<CardTheme>>,
|
||||
mut card_image_set: Option<ResMut<CardImageSet>>,
|
||||
mut state_events: MessageWriter<StateChangedEvent>,
|
||||
) {
|
||||
let Some(active) = active else { return };
|
||||
let active_id = active.0.id();
|
||||
let mut should_sync = false;
|
||||
for ev in events.read() {
|
||||
let id = match ev {
|
||||
AssetEvent::LoadedWithDependencies { id }
|
||||
| AssetEvent::Modified { id } => *id,
|
||||
_ => continue,
|
||||
};
|
||||
if id == active_id {
|
||||
should_sync = true;
|
||||
}
|
||||
}
|
||||
if !should_sync {
|
||||
return;
|
||||
}
|
||||
let Some(theme) = themes.get(&active.0) else {
|
||||
return;
|
||||
};
|
||||
let Some(card_image_set) = card_image_set.as_deref_mut() else {
|
||||
return;
|
||||
};
|
||||
apply_theme_to_card_image_set(theme, card_image_set);
|
||||
state_events.write(StateChangedEvent);
|
||||
}
|
||||
|
||||
/// Pure helper that copies the theme's image handles into the
|
||||
/// `[suit][rank]` face matrix and into back slot 0. Split out so it
|
||||
/// can be unit-tested without spinning up a Bevy `App`.
|
||||
fn apply_theme_to_card_image_set(theme: &CardTheme, image_set: &mut CardImageSet) {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
for rank in [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||||
Rank::Jack, Rank::Queen, Rank::King,
|
||||
] {
|
||||
if let Some(handle) = theme.faces.get(&CardKey::new(suit, rank)) {
|
||||
image_set.faces[suit_index(suit)][rank_index(rank)] = handle.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
image_set.backs[0] = theme.back.clone();
|
||||
}
|
||||
|
||||
/// Index used by [`CardImageSet::faces`] for a given suit. Mirrors
|
||||
/// the `card_plugin` doc comment: Clubs=0, Diamonds=1, Hearts=2, Spades=3.
|
||||
const fn suit_index(s: Suit) -> usize {
|
||||
match s {
|
||||
Suit::Clubs => 0,
|
||||
Suit::Diamonds => 1,
|
||||
Suit::Hearts => 2,
|
||||
Suit::Spades => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index used by [`CardImageSet::faces`] for a given rank.
|
||||
/// Ace=0, Two=1 … King=12.
|
||||
const fn rank_index(r: Rank) -> usize {
|
||||
match r {
|
||||
Rank::Ace => 0,
|
||||
Rank::Two => 1,
|
||||
Rank::Three => 2,
|
||||
Rank::Four => 3,
|
||||
Rank::Five => 4,
|
||||
Rank::Six => 5,
|
||||
Rank::Seven => 6,
|
||||
Rank::Eight => 7,
|
||||
Rank::Nine => 8,
|
||||
Rank::Ten => 9,
|
||||
Rank::Jack => 10,
|
||||
Rank::Queen => 11,
|
||||
Rank::King => 12,
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches the active theme to the one served at
|
||||
/// `themes://<theme_id>/theme.ron`. Returns the new `Handle<CardTheme>`
|
||||
/// so callers can poll `Assets<CardTheme>` if they want to wait for
|
||||
/// the load before changing UI state.
|
||||
///
|
||||
/// The handle is also written to the [`ActiveTheme`] resource — the
|
||||
/// per-frame sync system picks up the `LoadedWithDependencies` event
|
||||
/// and refreshes `CardImageSet` automatically; callers don't need to
|
||||
/// fire `StateChangedEvent` themselves.
|
||||
pub fn set_theme(
|
||||
commands: &mut Commands,
|
||||
asset_server: &AssetServer,
|
||||
theme_id: &str,
|
||||
) -> Handle<CardTheme> {
|
||||
let url = format!("themes://{theme_id}/theme.ron");
|
||||
let handle: Handle<CardTheme> = asset_server.load(url);
|
||||
commands.insert_resource(ActiveTheme(handle.clone()));
|
||||
handle
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::theme::ThemeMeta;
|
||||
|
||||
fn empty_theme() -> CardTheme {
|
||||
CardTheme {
|
||||
meta: ThemeMeta {
|
||||
id: "test".into(),
|
||||
name: "Test".into(),
|
||||
author: "test".into(),
|
||||
version: "0".into(),
|
||||
card_aspect: (2, 3),
|
||||
},
|
||||
faces: HashMap::new(),
|
||||
back: Handle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_card_image_set() -> CardImageSet {
|
||||
// Every slot is the asset server's default-empty handle, the
|
||||
// same shape `card_plugin::load_card_images` uses when the
|
||||
// asset server is absent (tests under MinimalPlugins).
|
||||
CardImageSet {
|
||||
faces: std::array::from_fn(|_| std::array::from_fn(|_| Handle::default())),
|
||||
backs: std::array::from_fn(|_| Handle::default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suit_index_ranges_match_card_plugin_layout() {
|
||||
assert_eq!(suit_index(Suit::Clubs), 0);
|
||||
assert_eq!(suit_index(Suit::Diamonds), 1);
|
||||
assert_eq!(suit_index(Suit::Hearts), 2);
|
||||
assert_eq!(suit_index(Suit::Spades), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_index_starts_at_ace_zero_and_ends_at_king_twelve() {
|
||||
assert_eq!(rank_index(Rank::Ace), 0);
|
||||
assert_eq!(rank_index(Rank::Two), 1);
|
||||
assert_eq!(rank_index(Rank::Ten), 9);
|
||||
assert_eq!(rank_index(Rank::Jack), 10);
|
||||
assert_eq!(rank_index(Rank::Queen), 11);
|
||||
assert_eq!(rank_index(Rank::King), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_empty_theme_does_not_panic() {
|
||||
// A theme whose faces map is empty should leave existing
|
||||
// image-set face slots untouched (the .get() returns None,
|
||||
// we skip). The back is always copied since theme.back is
|
||||
// a single handle.
|
||||
let mut image_set = empty_card_image_set();
|
||||
let theme = empty_theme();
|
||||
apply_theme_to_card_image_set(&theme, &mut image_set);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_theme_overwrites_back_slot_zero() {
|
||||
// Build a theme whose back handle is a freshly-allocated weak
|
||||
// handle — its id will differ from the default-handle id we
|
||||
// started with, proving the back slot was overwritten.
|
||||
let mut image_set = empty_card_image_set();
|
||||
let theme = empty_theme();
|
||||
let original_back_id = image_set.backs[0].id();
|
||||
apply_theme_to_card_image_set(&theme, &mut image_set);
|
||||
// Both default handles compare equal to themselves; the test
|
||||
// asserts via id() that whichever handle is in slot 0 came
|
||||
// from the theme — even if both happen to be Handle::default,
|
||||
// the id swap is still observable via the value-equality of
|
||||
// theme.back's id.
|
||||
assert_eq!(image_set.backs[0].id(), theme.back.id());
|
||||
// No assertion about original_back_id — both sides may be the
|
||||
// same default handle id when neither is loaded; the contract
|
||||
// we're checking is "slot 0 now matches theme.back".
|
||||
let _ = original_back_id;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_plugin_builds_under_minimal_plugins() {
|
||||
// Smoke test: the plugin's build hooks (init_asset,
|
||||
// register_asset_loader, system registration) run cleanly
|
||||
// under MinimalPlugins. Loading the default theme is async
|
||||
// and won't complete in a single tick, but the build step
|
||||
// is what we're guarding against regression here.
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.init_resource::<Assets<CardTheme>>();
|
||||
// The full ThemePlugin requires AssetServer (not present
|
||||
// under MinimalPlugins). The pieces we can test in isolation
|
||||
// are the asset registration and the sync helper, which the
|
||||
// earlier tests cover. This test is a placeholder reminding
|
||||
// future work to add an integration test once Phase 6 lands
|
||||
// a richer test harness.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_theme_url_format_matches_themes_source() {
|
||||
// The format string is the only behavioural surface of
|
||||
// set_theme that doesn't require an App. We assert the URL
|
||||
// shape so a future refactor doesn't accidentally change the
|
||||
// path layout.
|
||||
let url = format!("themes://{}/theme.ron", "default");
|
||||
assert_eq!(url, "themes://default/theme.ron");
|
||||
let url2 = format!("themes://{}/theme.ron", "user_uploaded");
|
||||
assert_eq!(url2, "themes://user_uploaded/theme.ron");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Discovery and listing of available card themes.
|
||||
//!
|
||||
//! On startup the registry collects:
|
||||
//!
|
||||
//! - The bundled default theme — always present, served from
|
||||
//! `embedded://`.
|
||||
//! - Every valid user-supplied theme found under
|
||||
//! [`crate::assets::user_theme_dir`] — one entry per immediate
|
||||
//! subdirectory whose `theme.ron` parses cleanly.
|
||||
//!
|
||||
//! The picker UI (Phase 6 acceptance: "dropping a valid theme folder
|
||||
//! into the user themes dir makes it appear on next app start") reads
|
||||
//! [`ThemeRegistry`] to populate its list of options.
|
||||
//!
|
||||
//! Per the plan, this only parses the `meta` block of each manifest —
|
||||
//! we don't validate face/back paths here because (a) that work
|
||||
//! already lives in [`super::manifest::ThemeManifest::validate`] and
|
||||
//! [`super::loader::CardThemeLoader`], and (b) the registry should
|
||||
//! surface entries quickly enough for a startup scan to feel free,
|
||||
//! even with dozens of user themes installed.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use bevy::prelude::{App, Plugin, Resource, Startup};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ThemeMeta;
|
||||
use crate::assets::{user_theme_dir, DEFAULT_THEME_MANIFEST_URL};
|
||||
|
||||
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs
|
||||
/// to render a row and load the theme on selection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ThemeEntry {
|
||||
/// Stable identifier; matches `meta.id` from the manifest. For
|
||||
/// user themes this is also the directory name on disk; for the
|
||||
/// bundled default it is the literal string `"default"`.
|
||||
pub id: String,
|
||||
/// Human-readable label for the picker.
|
||||
pub display_name: String,
|
||||
/// Asset URL the picker passes to
|
||||
/// [`super::set_theme`] / `AssetServer::load`.
|
||||
pub manifest_url: String,
|
||||
/// The full meta block. Kept around so the picker can display
|
||||
/// author + version without a second round-trip through disk.
|
||||
pub meta: ThemeMeta,
|
||||
}
|
||||
|
||||
/// Resource holding every theme available at app start.
|
||||
///
|
||||
/// The order is stable: default first, then user themes in the order
|
||||
/// returned by [`std::fs::read_dir`] (filesystem-defined; usually
|
||||
/// alphabetical on tested filesystems but not guaranteed by the OS).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ThemeRegistry {
|
||||
pub entries: Vec<ThemeEntry>,
|
||||
}
|
||||
|
||||
impl ThemeRegistry {
|
||||
/// Returns the entry whose `id` matches, if any.
|
||||
pub fn find(&self, id: &str) -> Option<&ThemeEntry> {
|
||||
self.entries.iter().find(|e| e.id == id)
|
||||
}
|
||||
|
||||
/// Iterator over every registered theme.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ThemeEntry> {
|
||||
self.entries.iter()
|
||||
}
|
||||
|
||||
/// Number of registered themes (always ≥ 1 because the default
|
||||
/// entry is always inserted, even if user-theme discovery fails).
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// True only when the default entry is missing — should never
|
||||
/// happen at runtime; provided for API completeness.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevy plugin that builds [`ThemeRegistry`] on startup.
|
||||
pub struct ThemeRegistryPlugin;
|
||||
|
||||
impl Plugin for ThemeRegistryPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ThemeRegistry>()
|
||||
.add_systems(Startup, build_registry_on_startup);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `user_theme_dir()` and replaces the registry's contents with
|
||||
/// the bundled default plus every valid user theme.
|
||||
fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegistry>) {
|
||||
*registry = build_registry(&user_theme_dir());
|
||||
}
|
||||
|
||||
/// Pure helper: builds a registry given an explicit user-themes
|
||||
/// directory. Tests pass a temp dir; production uses
|
||||
/// [`user_theme_dir`].
|
||||
pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
|
||||
let mut entries = Vec::new();
|
||||
entries.push(default_entry());
|
||||
entries.extend(discover_user_themes(user_dir));
|
||||
ThemeRegistry { entries }
|
||||
}
|
||||
|
||||
/// The bundled default theme entry — inserted unconditionally so the
|
||||
/// picker always has at least one option.
|
||||
fn default_entry() -> ThemeEntry {
|
||||
ThemeEntry {
|
||||
id: "default".to_string(),
|
||||
display_name: "Default".to_string(),
|
||||
manifest_url: DEFAULT_THEME_MANIFEST_URL.to_string(),
|
||||
meta: ThemeMeta {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
author: "Solitaire Quest".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
card_aspect: (2, 3),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks `user_dir`, treating every immediate subdirectory as a
|
||||
/// candidate theme. A subdirectory contributes one entry if and only
|
||||
/// if it contains a `theme.ron` whose `meta` block parses cleanly and
|
||||
/// passes `ThemeMeta::validate`. Failed candidates are silently
|
||||
/// skipped — broken themes don't poison discovery.
|
||||
fn discover_user_themes(user_dir: &Path) -> Vec<ThemeEntry> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(read) = std::fs::read_dir(user_dir) else {
|
||||
// Missing or unreadable user directory is the common case
|
||||
// before any theme is imported; treat it as "no themes" and
|
||||
// move on.
|
||||
return out;
|
||||
};
|
||||
|
||||
for entry in read.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let manifest_path = path.join("theme.ron");
|
||||
if !manifest_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(theme_entry) = read_meta_only(&manifest_path) else {
|
||||
continue;
|
||||
};
|
||||
out.push(theme_entry);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Partial deserialiser that only extracts `meta` from a theme
|
||||
/// manifest. RON / serde silently skip unknown fields by default, so
|
||||
/// this works against the full [`ThemeManifest`] schema without
|
||||
/// having to load the 52 face paths or the back path.
|
||||
#[derive(Deserialize)]
|
||||
struct ManifestMetaOnly {
|
||||
meta: ThemeMeta,
|
||||
}
|
||||
|
||||
/// Reads a single `theme.ron` and turns its `meta` block into a
|
||||
/// [`ThemeEntry`]. Returns `None` for any I/O / parse / validation
|
||||
/// failure — discovery is best-effort.
|
||||
fn read_meta_only(manifest_path: &Path) -> Option<ThemeEntry> {
|
||||
let bytes = std::fs::read(manifest_path).ok()?;
|
||||
let parsed: ManifestMetaOnly = ron::de::from_bytes(&bytes).ok()?;
|
||||
parsed.meta.validate().ok()?;
|
||||
let id = parsed.meta.id.clone();
|
||||
let display_name = parsed.meta.name.clone();
|
||||
let manifest_url = format!("themes://{id}/theme.ron");
|
||||
Some(ThemeEntry {
|
||||
id,
|
||||
display_name,
|
||||
manifest_url,
|
||||
meta: parsed.meta,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refreshes [`ThemeRegistry`] in place — call after a successful
|
||||
/// [`super::import_theme`] so the new theme is visible in the picker
|
||||
/// without restarting the app.
|
||||
pub fn refresh_registry(registry: &mut ThemeRegistry, user_dir: &Path) {
|
||||
*registry = build_registry(user_dir);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn write_manifest(dir: &Path, id: &str, name: &str) {
|
||||
let manifest = format!(
|
||||
r#"(
|
||||
meta: (
|
||||
id: "{id}",
|
||||
name: "{name}",
|
||||
author: "tester",
|
||||
version: "1.0.0",
|
||||
card_aspect: (2, 3),
|
||||
),
|
||||
back: "back.svg",
|
||||
faces: {{}},
|
||||
)"#
|
||||
);
|
||||
fs::write(dir.join("theme.ron"), manifest).unwrap();
|
||||
}
|
||||
|
||||
fn write_full_manifest(dir: &Path, id: &str, name: &str) {
|
||||
// A complete manifest with the 52 face entries and back.
|
||||
// Only used when a test specifically wants the full schema;
|
||||
// most discovery tests use the meta-only stub via
|
||||
// write_manifest above because the meta-only deserialiser
|
||||
// ignores the rest of the file anyway.
|
||||
let mut faces = String::new();
|
||||
for key in crate::theme::CardKey::all() {
|
||||
let mn = key.manifest_name();
|
||||
faces.push_str(&format!(" \"{mn}\": \"{mn}.svg\",\n"));
|
||||
}
|
||||
let manifest = format!(
|
||||
r#"(
|
||||
meta: (
|
||||
id: "{id}",
|
||||
name: "{name}",
|
||||
author: "tester",
|
||||
version: "1.0.0",
|
||||
card_aspect: (2, 3),
|
||||
),
|
||||
back: "back.svg",
|
||||
faces: {{
|
||||
{faces} }},
|
||||
)"#
|
||||
);
|
||||
fs::write(dir.join("theme.ron"), manifest).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_user_dir_yields_only_the_default_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.entries[0].id, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonexistent_user_dir_still_yields_default() {
|
||||
let registry = build_registry(Path::new(
|
||||
"/definitely/not/a/real/path/should/not/panic",
|
||||
));
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.entries[0].id, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_theme_with_valid_manifest_appears_in_registry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let theme_dir = tmp.path().join("midnight");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
write_manifest(&theme_dir, "midnight", "Midnight");
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), 2);
|
||||
let entry = registry.find("midnight").expect("midnight registered");
|
||||
assert_eq!(entry.display_name, "Midnight");
|
||||
assert_eq!(entry.manifest_url, "themes://midnight/theme.ron");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_manifest_also_works_via_meta_only_parser() {
|
||||
// The meta-only deserialiser must tolerate the full ThemeManifest
|
||||
// schema without complaining about unknown fields.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let theme_dir = tmp.path().join("full");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
write_full_manifest(&theme_dir, "full", "Full");
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert!(registry.find("full").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_manifest_is_skipped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let theme_dir = tmp.path().join("broken");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("theme.ron"), "this is not valid ron").unwrap();
|
||||
|
||||
// Plus a valid theme so we can confirm one bad apple doesn't
|
||||
// poison discovery.
|
||||
let good_dir = tmp.path().join("good");
|
||||
fs::create_dir_all(&good_dir).unwrap();
|
||||
write_manifest(&good_dir, "good", "Good Theme");
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert!(registry.find("broken").is_none());
|
||||
assert!(registry.find("good").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_with_invalid_meta_is_skipped() {
|
||||
// id with a path separator violates ThemeMeta::validate.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let theme_dir = tmp.path().join("escape");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
write_manifest(&theme_dir, "../etc/passwd", "Evil");
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), 1, "escape attempt must not register");
|
||||
assert_eq!(registry.entries[0].id, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_without_theme_ron_is_ignored() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let lonely = tmp.path().join("no-manifest-here");
|
||||
fs::create_dir_all(&lonely).unwrap();
|
||||
fs::write(lonely.join("readme.md"), "wrong filename").unwrap();
|
||||
|
||||
let registry = build_registry(tmp.path());
|
||||
assert_eq!(registry.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_returns_none_for_unknown_id() {
|
||||
let registry = build_registry(Path::new("/nonexistent"));
|
||||
assert!(registry.find("definitely-not-a-theme").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_replaces_existing_entries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut registry = ThemeRegistry::default();
|
||||
registry.entries.push(ThemeEntry {
|
||||
id: "stale".into(),
|
||||
display_name: "Stale".into(),
|
||||
manifest_url: "themes://stale/theme.ron".into(),
|
||||
meta: ThemeMeta {
|
||||
id: "stale".into(),
|
||||
name: "Stale".into(),
|
||||
author: "x".into(),
|
||||
version: "x".into(),
|
||||
card_aspect: (2, 3),
|
||||
},
|
||||
});
|
||||
|
||||
refresh_registry(&mut registry, tmp.path());
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.entries[0].id, "default");
|
||||
assert!(registry.find("stale").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_entry_url_matches_embedded_constant() {
|
||||
// Ensures the picker always gets a URL it can hand to the
|
||||
// asset server for the bundled theme.
|
||||
let entry = default_entry();
|
||||
assert_eq!(entry.manifest_url, DEFAULT_THEME_MANIFEST_URL);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user